From 6e98622c332eaedf99ea1cd0e45833252d9d87bf Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 25 Sep 2026 13:16:49 +0000 Subject: [PATCH 01/71] fix(cli): declare the @libpg-query/parser import plpgsql-deparser leaves undeclared (#6827) ## Summary Running `bun scripts/build-binary.ts` directly in `apps/cli` fails with `Could not resolve: "@libpg-query/parser"` from `plpgsql-deparser/esm/hydrate.js`, on `develop` as well as on feature branches. `pnpm build:binary` and the turbo `supabase#build` task succeed, which is why CI never sees it. The cause is in the dependency, not the build script. `plpgsql-deparser@0.7.13` imports `@libpg-query/parser` in `esm/hydrate.js` but declares only `@pgsql/types` and `pgsql-deparser`; the package that does declare the parser is its parent, `plpgsql-parser`. With pnpm's strict layout the deparser cannot see an undeclared package. It resolves under `pnpm run` only because pnpm exports `NODE_PATH` pointing at `node_modules/.pnpm/node_modules`, the hidden hoist directory, and Bun consults `NODE_PATH` when resolving imports. Invoke Bun without that environment and the fallback is gone. This adds a `packageExtensions` entry for `plpgsql-deparser` declaring `@libpg-query/parser` with the same range `plpgsql-parser` uses, so it dedupes to the already installed and patched `17.6.10`. That is the mechanism the workspace file already uses for `bun-types` and `@supabase/pg-delta`, which import undeclared packages in the same way. ## What changed - `pnpm-workspace.yaml`: the `packageExtensions` entry. - `pnpm-lock.yaml`: the `packageExtensionsChecksum` and the deparser's dependency list. Verified by deleting `apps/cli/dist/supabase` and running `bun scripts/build-binary.ts` with `NODE_PATH` and `NODE_OPTIONS` unset; the binary builds and runs. A frozen-lockfile install passes. ## Linked issue No GitHub issue; found while building supabase/cli#6822 locally. The upstream fix would be `plpgsql-deparser` declaring the dependency itself; this entry can go once a release does. --- pnpm-lock.yaml | 3 ++- pnpm-workspace.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f1bcbba39..f0474674a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,7 +231,7 @@ catalogs: overrides: '@launchql/protobufjs>@types/node': 24.10.4 -packageExtensionsChecksum: sha256-+q1Reu9SIvF0cNyYHg1TxU5zvG0d67iLruu2/1HlPHo= +packageExtensionsChecksum: sha256-gAA6Mc6Jn3jUpogzVWO9hE5trVRioaB8peQBgDbyG6I= patchedDependencies: '@effect/vitest@4.0.0-rc.112': fbd126f7e68e041231312d90b8c02f705f12b66c2cb2d6fc246f5c12a4ed9787 @@ -11816,6 +11816,7 @@ snapshots: plpgsql-deparser@0.7.13: dependencies: + '@libpg-query/parser': 17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3) '@pgsql/types': 17.6.2 pgsql-deparser: 17.18.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0f80497c5..bf029b2f75 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,6 +55,9 @@ packageExtensions: # imports pg and debug without shipping their @types packages. "@types/debug": "^4.1.12" "@types/pg": "^8.23.1" + plpgsql-deparser: + dependencies: + "@libpg-query/parser": "^17.6.3" # vitest lists vite as both a dependency and a peer, so pnpm resolves it as a # peer only. These consumers reach vitest through their own `vitest` peer inside a # peer cycle, which leaves them a vitest variant with no vite at all — fatal From e83651930de7f365b8e3d9e8800cf5f975a56177 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Fri, 25 Sep 2026 13:51:30 +0000 Subject: [PATCH 02/71] fix(stack): clean up stacks when Docker is unavailable on start and destroy (#6825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem With Docker unavailable, `supabase stack start --runtime docker` failed and printed `Failed to stop stack host : ...`, leaving a stale `runtime: docker` registration behind. A follow-up `stack start --runtime native` for the same project then failed with a runtime-mismatch error, and the suggested `stack stop`/`stack destroy` failed too, since both need an owner that can't start without Docker. This PR fixes both failure paths so a stack never gets stuck this way. Refs: https://linear.app/supabase/issue/CLI-2500/clean-up-stacks-when-docker-is-unavailable-on-start ## Case 1: `stack start` leaves a stack behind when Docker is unavailable `Stack.create` saved the new stack's registration before ever starting its owner. When the first owner-backed call then failed to launch (Docker unreachable), the registration was already saved, and cleanup (`stack.stop`) failed too, because it also needs the owner that couldn't start. `create` now takes a `startOwner` option: when set, it launches the owner as part of creation and rolls back the registration it just saved if that launch fails or is interrupted. The rollback keeps the stack when an owner holds it: one that reported ready before an interrupt, or one another caller launched in the meantime. `stack start` opts into this for new stacks, so a Docker failure at creation now surfaces as one clean error, with no separate "Failed to stop" diagnostic and nothing left registered. **Test it:** ```sh DOCKER_HOST=tcp://127.0.0.1:1 SUPABASE_EXPERIMENTAL_STACK=1 supabase stack start --runtime docker # one Docker error, no "Failed to stop stack host" line, no leftover ~/.supabase/stacks// SUPABASE_EXPERIMENTAL_STACK=1 supabase stack start --runtime native # starts cleanly, no runtime-mismatch error ``` ## Case 2: `stack destroy` fails outright when Docker is unavailable `stack destroy` also needs to start an owner to do the cleanup, and that owner's startup re-runs the same container sweep `start` does. With Docker down, that sweep fails before an owner exists, so `destroy` failed with a raw `Cause([Fail(StackHostError: ...)])` message and left the stack registered. `destroy` now falls back to removing the stack's registration and host data itself when no owner is running and the container engine can't be reached: - **What counts as unreachable:** the engine reports its daemon isn't listening (`Cannot connect to the Docker daemon`, `connection refused`, a missing socket such as Docker 29's `connect: no such file or directory`, or the Windows daemon-down forms of `error during connect`), or the `docker`/`podman` CLI isn't installed. Generic wrappers that also carry authentication failures, such as `Cannot connect to Podman`, only count when their underlying cause is one of those. Permission, TLS, authentication, and timeout errors still fail and keep the stack registered, as does a reachable engine that fails to remove a container. - **Concurrent owners:** under the state lock, destroy checks whether anything holds the stack's control port and refuses to delete if an owner started in the meantime. - **Host data only the engine can remove:** if any directory under the stack's data can't be deleted by the current user (for example, database files a container wrote as its own user on Docker below 26), destroy fails before removing anything and asks to start the engine and retry. - **What's left behind:** the stack's containers, and any database data kept in the shared Docker volume. `destroy` returns `{ runtimeCleanup: "skipped", engine, cleanupCommands }`, with one command that removes the containers (filtered by stack id and stack root, and succeeding when none are left) and one per database volume directory. CLI behaviour in the fallback: - `stack destroy` exits 0, warns on stderr with the cleanup commands, and prints `Stack was removed locally; its Docker resources remain until the commands above are run.` instead of `Stack destroyed.` - JSON output adds `runtimeCleanup` (`complete` or `skipped`), plus `engine` and `cleanupCommands` when skipped, next to the existing `destroyed` and `id`. - Shadow-database commands (`db diff`, `db pull`, `migration squash`, declarative sync) print the same cleanup commands when their temporary stack's destroy is skipped. **Test it:** ```sh SUPABASE_EXPERIMENTAL_STACK=1 supabase stack start --runtime docker SUPABASE_EXPERIMENTAL_STACK=1 supabase stack stop DOCKER_HOST=tcp://127.0.0.1:1 SUPABASE_EXPERIMENTAL_STACK=1 supabase stack destroy --yes # exits 0, lists the cleanup commands on stderr, and removes the local registration SUPABASE_EXPERIMENTAL_STACK=1 supabase stack start --runtime native # starts cleanly, no runtime-mismatch error # with Docker running again, the printed commands remove the leftover containers and database data ``` ## Coverage - `packages/stack/src/create.owner-rollback.integration.test.ts` (new): rollback on a failed or interrupted owner launch during `create`, and a same-identity retry with a different runtime. - `packages/stack/src/destroy.runtime-unavailable.integration.test.ts` (new): offline removal and exact cleanup commands for an unreachable daemon, Docker 29's missing-socket message, a Windows daemon-down message, and a missing CLI; the printed container command run against an unreachable and an empty engine; a rejected listing, a Podman authentication failure, undeletable host data, and a reachable-engine cleanup failure all still fail and keep the registration. - `packages/stack/src/HostProcess.integration.test.ts`: the control-port check used by both rollbacks. - `apps/cli/.../start/start.integration.test.ts`: a new stack whose owner can't reach Docker, through the real stack API, leaves nothing registered. - `apps/cli/.../destroy/destroy.runtime-unavailable.integration.test.ts` (new): text and JSON output for a skipped cleanup. - `apps/cli/src/command-internal/stack-shadow.integration.test.ts`: shadow commands print the cleanup commands. - `apps/cli/.../start/start.e2e.test.ts`: one compiled-binary golden path for the Docker-unavailable create failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5 --- apps/cli/src/command-internal/stack-api.ts | 13 + .../stack-shadow.integration.test.ts | 56 ++++ apps/cli/src/command-internal/stack-shadow.ts | 10 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 4 +- .../commands/db/dump/dump.integration.test.ts | 2 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 4 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 4 +- .../db/start/start.integration.test.ts | 2 +- .../stack/destroy/SIDE_EFFECTS.md | 17 +- .../stack/destroy/destroy.handler.ts | 15 +- ...oy.runtime-unavailable.integration.test.ts | 111 +++++++ .../stack/prepare/prepare.integration.test.ts | 2 +- .../experimental/stack/stack.shared.ts | 8 +- .../experimental/stack/start/SIDE_EFFECTS.md | 16 +- .../stack/start/start.e2e.test.ts | 47 +++ .../experimental/stack/start/start.handler.ts | 1 + .../stack/start/start.integration.test.ts | 98 +++++- .../commands/migration/squash/SIDE_EFFECTS.md | 4 +- packages/stack/README.md | 4 + .../stack/src/HostProcess.integration.test.ts | 27 +- packages/stack/src/HostProcess.ts | 21 +- packages/stack/src/StackHost.ts | 14 +- .../create.owner-rollback.integration.test.ts | 101 ++++++ ...oy.runtime-unavailable.integration.test.ts | 311 ++++++++++++++++++ packages/stack/src/effect.ts | 155 ++++++++- packages/stack/src/index.ts | 2 +- packages/stack/src/internal/host-process.ts | 9 +- packages/stack/src/runtime/Container.ts | 50 ++- .../src/storage/DockerDatabaseStorage.ts | 40 +++ 29 files changed, 1100 insertions(+), 48 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts create mode 100644 packages/stack/src/create.owner-rollback.integration.test.ts create mode 100644 packages/stack/src/destroy.runtime-unavailable.integration.test.ts diff --git a/apps/cli/src/command-internal/stack-api.ts b/apps/cli/src/command-internal/stack-api.ts index ed753d6071..bc6221b4d1 100644 --- a/apps/cli/src/command-internal/stack-api.ts +++ b/apps/cli/src/command-internal/stack-api.ts @@ -6,6 +6,7 @@ import { discover, open, type CreateOptions, + type DestroyResult, type OpenOptions, type Stack, } from "@supabase/stack/effect"; @@ -69,4 +70,16 @@ export const stackApiLayer = Layer.effect( }), ).pipe(Layer.provide(FetchHttpClient.layer)); +/** Describes the engine resources a destroy left behind and the commands that remove them. */ +export const skippedRuntimeCleanupWarning = ( + subject: string, + result: Extract, +): string => { + const engineName = result.engine === "docker" ? "Docker" : "Podman"; + return [ + `${engineName} was unavailable, so ${engineName} resources for ${subject} were not removed. Once it is running, remove them with:`, + ...result.cleanupCommands.map((command) => ` ${command}`), + ].join("\n"); +}; + export type { Stack }; diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index c67aa94faf..8fe9849ca3 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -456,4 +456,60 @@ describe("stack shadow databases", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), 120_000, ); + + it.live("prints the cleanup commands when a shadow's destroy skips its engine", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-shadow-skipped-" }); + const output = mockOutput(); + const cleanupCommand = "docker rm --force $(docker ps --all --quiet)"; + // A real registration whose database creation fails fast and whose destroy reports skipped cleanup. + const api = Layer.effect( + StackApi, + Effect.gen(function* () { + const real = yield* StackApi; + return StackApi.of({ + ...real, + create: (options) => + real.create(options).pipe( + Effect.map((stack) => ({ + ...stack, + services: { + ...stack.services, + create: () => + Effect.fail(new StackError({ operation: "create", message: "injected" })), + }, + destroy: Effect.succeed({ + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [cleanupCommand], + } as const), + })), + ), + }); + }), + ).pipe(Layer.provide(stackApiLayer), Layer.provide(BunServices.layer)); + + yield* stackWithShadowDatabase(input(fs, path, root), () => Effect.void, { + runtime: "native", + }).pipe( + Effect.provide( + Layer.mergeAll( + api, + stackCatalogSetupLayer, + dbConnectionLayer, + runtimeInfoLayer, + mockCommandSettings({ workdir: root, supabaseHome: root }), + output.layer, + ), + ), + Effect.flip, + ); + + expect(output.stderrText).toMatch( + /Warning: Docker was unavailable, so Docker resources for shadow stack [0-9a-f]{64} were not removed\. Once it is running, remove them with:\n {2}docker rm --force \$\(docker ps --all --quiet\)\n/u, + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index 1e6550d24d..a11bd98e56 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -3,7 +3,7 @@ import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { CommandSettings } from "../config/command-settings.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; -import { StackApi } from "./stack-api.ts"; +import { skippedRuntimeCleanupWarning, StackApi } from "./stack-api.ts"; import { StackCatalogSetup } from "./stack-catalog-setup.ts"; import { stackProjectRuntime } from "./stack-local-database.ts"; import { defaultStackRuntime } from "./stack-runtime.ts"; @@ -220,6 +220,14 @@ export const stackAcquireShadowDatabase = Effect.fn("StackShadow.acquire")(funct const output = yield* Output; const namespace = yield* Effect.acquireRelease(acquireNamespace(opts), ({ stack }) => stack.destroy.pipe( + Effect.flatMap((result) => + result.runtimeCleanup === "skipped" + ? output.raw( + `Warning: ${skippedRuntimeCleanupWarning(`shadow stack ${stack.id}`, result)}\n`, + "stderr", + ) + : Effect.void, + ), Effect.catch((cause) => output.raw( `Failed to destroy shadow stack ${stack.id}: ${cause.message}. Run supabase stack destroy --stack-id ${stack.id} to remove it.\n`, diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 1529a7a36f..d75d71fb88 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -10,7 +10,9 @@ pending port. When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, -then destroys its namespace when the Effect scope closes. Stack shadows use the stack baseline +then destroys its namespace when the Effect scope closes. If its container engine is unreachable +then, the namespace is still removed and stderr lists the commands that remove its engine +resources. Stack shadows use the stack baseline cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 795efa8de8..4ffc61ae51 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -118,7 +118,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { restart: Effect.succeed([]), }, stop: Effect.void, - destroy: Effect.void, + destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), tools: { run: ( _tool: { readonly command: string; readonly major: number }, diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index b159d490d2..966fc61738 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -16,7 +16,9 @@ runs the same in-process declarative export (`supabase/schemas` plus When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, -then destroys its namespace when the Effect scope closes. Stack shadows use the stack baseline +then destroys its namespace when the Effect scope closes. If its container engine is unreachable +then, the namespace is still removed and stderr lists the commands that remove its engine +resources. Stack shadows use the stack baseline cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 0e04a63815..d9fb9bdfd1 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -6,7 +6,9 @@ as a new timestamped migration. When `[experimental].stack` is on, each shadow uses a fresh, invocation-owned stack namespace with an automatically assigned port. State and data live under `$SUPABASE_HOME/stacks`, and native artifacts are shared through `$SUPABASE_HOME/cache/stack`. The command destroys its -shadow namespaces when its Effect scope closes. An abrupt process exit can leave a namespace +shadow namespaces when its Effect scope closes; if their container engine is unreachable then, the +namespaces are still removed and stderr lists the commands that remove their engine resources. +An abrupt process exit can leave a namespace visible to `stack list` for manual `stack destroy` cleanup. Stack shadows use the stack baseline cache described below; `--no-cache` bypasses it as well as the legacy backend cache. diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index f0fccedeb2..dfc2d09c2b 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1705,7 +1705,7 @@ describe("db start stack backend", () => { restart: Effect.succeed([]), }, stop: Effect.void, - destroy: Effect.void, + destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), tools: { run: () => Effect.die("unused") }, }; return { stack, state }; diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 672f1c7f07..97c47b8e32 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -21,6 +21,16 @@ Cleanup failures remain errors; the command does not claim success on failure. A failed destroy may leave its owner running; retry destruction or use `stack stop` to shut down that owner. +When no owner is running and the engine reports that its daemon cannot be +reached, destruction removes the local namespace and host data anyway, warns on +stderr that the stack's engine resources were not removed, and exits 0. The +warning lists the commands that remove them once the engine is running: one for +the stack's containers, and one per database whose data is kept in an engine +volume. Any other engine failure, an owner starting during destruction, or +host data the current user cannot delete (such as database files a container +wrote as its own user), fails the command before anything is removed and keeps +the stack registered; the last case asks to start the engine and retry. + ## Files and network Reads identity/state under `/stacks//` and, for @@ -32,8 +42,11 @@ routing/settings can read project configuration and profiles. ## Output, exit codes and telemetry -Text prints `Stack destroyed.`; JSON and stream-json success data contain -`destroyed: true` and `id`. Exit 0 on destruction, 1 on invalid flags, missing +Text prints `Stack destroyed.`, or, when engine cleanup was skipped, +`Stack was removed locally; its resources remain until the commands above are run.` +JSON and stream-json success data contain `destroyed: true`, `id`, and +`runtimeCleanup` (`complete` or `skipped`); a skipped cleanup also carries `engine` and +`cleanupCommands`. Exit 0 on destruction, 1 on invalid flags, missing selection, rejected/cancelled confirmation or cleanup failure, and 130 on interruption. Standard command telemetry is unchanged, with no custom events. Telemetry flushes on success and failure to diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 9bdae4642a..588436ae75 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -10,6 +10,7 @@ import { StackApi, StackTargetError, rejectStackOutput, + skippedRuntimeCleanupWarning, StackTargetResolver, validateStackTarget, } from "../stack.shared.ts"; @@ -91,7 +92,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( }) .pipe(Effect.mapError(destroyError)); const destroying = yield* output.task(`Destroying stack ${target.id}...`); - yield* stack.destroy.pipe( + const result = yield* stack.destroy.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) ? destroying.clear() @@ -101,8 +102,16 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( ), Effect.mapError(destroyError), ); - if (output.format === "text") yield* output.raw(`Stack ${target.id} destroyed.\n`); - else yield* output.success("", { destroyed: true, id: target.id }); + if (result.runtimeCleanup === "skipped") + yield* output.warn(skippedRuntimeCleanupWarning(`stack ${target.id}`, result)); + if (output.format !== "text") + yield* output.success("", { destroyed: true, id: target.id, ...result }); + else if (result.runtimeCleanup === "complete") + yield* output.raw(`Stack ${target.id} destroyed.\n`); + else + yield* output.raw( + `Stack ${target.id} was removed locally; its ${result.engine === "docker" ? "Docker" : "Podman"} resources remain until the commands above are run.\n`, + ); }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts new file mode 100644 index 0000000000..625b9339f4 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts @@ -0,0 +1,111 @@ +import { BunServices } from "@effect/platform-bun"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; +import { stackDestroy } from "./destroy.handler.ts"; + +const live = Layer.provideMerge(stackApiLayer, BunServices.layer); + +const fixture = Effect.fn("StackDestroyRuntimeUnavailableTest.fixture")(function* ( + format: "text" | "json", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-runtime-" }); + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, "docker"); + yield* fs.writeFileString( + dockerShim, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const api = yield* StackApi; + const locations = { stateRoot: path.join(root, "stacks"), cacheRoot: path.join(root, "cache") }; + const stack = yield* api.create({ ...locations, projectRoot: root, runtime: "docker" }); + const output = mockOutput({ interactive: false, format }); + const telemetry = mockTelemetryStateTracked(); + const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); + const layer = Layer.mergeAll( + output.layer, + telemetry.layer, + settings, + stackTargetResolverLayer.pipe(Layer.provide(settings)), + mockTty({ stdinIsTty: false }), + mockStdin(false), + Layer.succeed(YesFlag, true), + Layer.succeed(CliArgs, { args: ["--yes"] }), + ); + return { + api, + locations, + stack, + output, + layer, + flags: { stack: Option.none(), stackId: Option.some(stack.id) }, + }; +}); + +const containerCleanup = (id: string) => + `sh -c 'ids=\\$\\(docker ps --all --quiet --no-trunc --filter '\\\\''label=com\\.supabase\\.stack=${id}'\\\\'' --filter '\\\\''label=com\\.supabase\\.stack-root=[^']+/${id}/data'\\\\''\\) && \\{ \\[ -z "\\$ids" \\] \\|\\| docker rm --force \\$ids; \\}'`; + +it.live( + "removes a stack locally and lists its cleanup commands when its engine is unreachable", + () => + Effect.gen(function* () { + const f = yield* fixture("text"); + yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer)); + expect(f.output.stdoutText).toBe( + `Stack ${f.stack.id} was removed locally; its Docker resources remain until the commands above are run.\n`, + ); + expect(f.output.messages).toContainEqual({ + type: "warn", + message: expect.stringMatching( + new RegExp( + `^Docker was unavailable, so Docker resources for stack ${f.stack.id} were not removed\\. Once it is running, remove them with:\\n ${containerCleanup(f.stack.id)}$`, + "u", + ), + ), + }); + expect(yield* f.api.discover(f.locations)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(live)), +); + +it.live("reports skipped engine cleanup and its commands in the JSON result", () => + Effect.gen(function* () { + const f = yield* fixture("json"); + yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer)); + expect(f.output.messages).toContainEqual( + expect.objectContaining({ + data: { + destroyed: true, + id: f.stack.id, + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [ + expect.stringMatching(new RegExp(`^${containerCleanup(f.stack.id)}$`, "u")), + ], + }, + }), + ); + expect(yield* f.api.discover(f.locations)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(live)), +); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index d2d5973673..09a5263eba 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -130,7 +130,7 @@ const makeFixture = (root: string, failPreparation = false) => { restart: Effect.succeed([]), }, stop: Effect.void, - destroy: Effect.void, + destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), tools: { run: () => Effect.die("unused") }, }; const output = mockOutput(); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 3556fcb13d..b3c19fb621 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -6,9 +6,13 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; +import { + skippedRuntimeCleanupWarning, + StackApi, + stackApiLayer, +} from "../../../command-internal/stack-api.ts"; -export { StackApi, stackApiLayer }; +export { skippedRuntimeCleanupWarning, StackApi, stackApiLayer }; type StackId = string; const isStackId = (id: string): boolean => /^[0-9a-f]{64}$/u.test(id); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 8547236dbc..88b2dea447 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -81,13 +81,15 @@ When configured, initial Storage bucket seeding creates buckets and uploads thei files using the service-role JWT. Storage is started and made ready before those requests. A resumed stack is not re-seeded. Projects without configured buckets make no bucket-seeding requests. -A failure or interruption during the first startup stops and unconfigures that initial composition, -then destroys only the service instances created by this invocation. When startup began without a -running owner, failure cleanup stops any owner launched during startup and waits for its exit. A -target with a running owner keeps it. Existing instances and their data are retained, and failed -resumes do not destroy existing data. Cleanup diagnostics name any instance that could not be removed -or owner that could not be stopped. After successful cleanup, fixing the cause and retrying starts -from an empty composition. +A new stack is registered and then its owner is launched; if the owner fails to launch (for example, +Docker is unavailable) or the launch is interrupted, the registration is removed, and the CLI reports +the single launch failure with no separate stop diagnostic. Any other failure or interruption during the first startup stops and +unconfigures that initial composition, then destroys only the service instances created by this +invocation. When startup began without a running owner, failure cleanup stops any owner launched +during startup and waits for its exit. A target with a running owner keeps it. Existing instances and +their data are retained, and failed resumes do not destroy existing data. Cleanup diagnostics name any +instance that could not be removed or owner that could not be stopped. After successful cleanup, +fixing the cause and retrying starts from an empty composition. Successful startup leaves the owner available after the CLI exits. Abrupt process termination that bypasses finalizers requires manual inspection and, for an incomplete first bootstrap, destruction before retrying; there is no recovery journal. diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index 6e4aecec72..f09f1988fa 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -92,6 +92,7 @@ const FollowEventSchema = Schema.Struct({ const VariablesSchema = Schema.Record(Schema.String, Schema.String); const StartResultSchema = Schema.Struct({ id: Schema.String }); +const StackListSchema = Schema.Struct({ stacks: Schema.Array(Schema.Unknown) }); const minimalConfig = `project_id = "compiled-stack-start-e2e" @@ -559,4 +560,50 @@ describe("stack start (compiled e2e)", () => { }), ), ); + + test( + "removes a stack registration that fails to start because Docker is unreachable", + { timeout: CLEANUP_TIMEOUT_MS }, + () => + runNode( + Effect.gen(function* () { + home = makeTempHome(); + projectDir = yield* makeTempDirectory("/tmp/supabase-stack-start-docker-down-e2e-"); + yield* makeDirectory(join(projectDir, "supabase"), { recursive: true }); + yield* writeText(join(projectDir, "supabase", "config.toml"), minimalConfig); + // A `docker` first on PATH that reports an unreachable daemon, as the real CLI does. + const binDir = join(projectDir, "bin"); + yield* makeDirectory(binDir); + yield* writeText( + join(binDir, "docker"), + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at unix:///shim/docker.sock. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* withFs((fs) => fs.chmod(join(binDir, "docker"), 0o755)); + + const result = yield* runSupabaseEffect(["stack", "start", "--runtime", "docker"], { + cwd: projectDir, + home: home.dir, + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the CLI subprocess resolves `docker` from PATH. + env: { PATH: `${binDir}:${process.env.PATH ?? ""}` }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(result.exitCode, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).not.toBe( + 0, + ); + expect(result.stderr).toContain("Cannot connect to the Docker daemon"); + expect(result.stderr).not.toContain("Failed to stop stack host"); + + const list = yield* runSupabaseEffect(["stack", "list", "--output-format", "json"], { + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(list.exitCode, `stdout:\n${list.stdout}\nstderr:\n${list.stderr}`).toBe(0); + const listed = yield* Schema.decodeEffect(Schema.fromJsonString(StackListSchema))( + list.stdout.trim(), + ); + expect(listed.stacks).toEqual([]); + }), + ), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index e831574030..812dcedda4 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -277,6 +277,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags stateRoot, cacheRoot, runtime: selectedRuntime, + startOwner: true, ...(target.name === undefined ? {} : { name: target.name }), }) : stackApi.open({ id: target.id, stateRoot, cacheRoot, startOwner: true }), diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 55226368fc..1b414bde07 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -33,7 +33,7 @@ import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer.ts"; -import { StackApi, StackTargetResolver } from "../stack.shared.ts"; +import { StackApi, stackApiLayer, StackTargetResolver } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; import { StackCommandStartError } from "./start.errors.ts"; @@ -174,6 +174,8 @@ const requireConcreteCreation = (creation: ServiceCreationInput): ServiceCreatio const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { let members: Array = []; let stopped = 0; + let hostStopped = 0; + let hostDestroyed = 0; let composed = 0; let catalogApplied = 0; let lifecycle: "stopped" | "running" = "stopped"; @@ -280,8 +282,13 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { }), restart: Effect.succeed([]), }, - stop: Effect.void, - destroy: Effect.void, + stop: Effect.sync(() => { + hostStopped += 1; + }), + destroy: Effect.sync(() => { + hostDestroyed += 1; + return { runtimeCleanup: "complete" as const }; + }), tools: { run: () => Effect.die("tool not used") }, }; return { @@ -292,6 +299,12 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { get stopped() { return stopped; }, + get hostStopped() { + return hostStopped; + }, + get hostDestroyed() { + return hostDestroyed; + }, get composed() { return composed; }, @@ -782,4 +795,83 @@ describe("experimental stack start", () => { expect(fixture.composed).toBe(2); }).pipe(Effect.provide(BunServices.layer)), ); + + it.live("leaves no stack registered when a new stack's owner cannot reach Docker", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-create-failure-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "create-failure"\n'); + yield* fs.makeDirectory(`${root}/bin`); + yield* fs.writeFileString( + `${root}/bin/docker`, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at unix:///shim/docker.sock. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* fs.chmod(`${root}/bin/docker`, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${root}/bin:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + const output = mockOutput(); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.succeed({ projectRoot: root, runtime: "docker" as const, hostRunning: false }), + }); + const api = stackApiLayer.pipe(Layer.provide(BunServices.layer)); + + const error = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), + ); + expect(error.message).toContain("Cannot connect to the Docker daemon"); + expect(output.stderrText).not.toContain("Failed to stop"); + + const stacks = yield* StackApi.pipe( + Effect.flatMap((stackApi) => stackApi.discover({ stateRoot: `${root}/.supabase/stacks` })), + Effect.provide(api), + ); + expect(stacks).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live( + "stops, without destroying, the owner when startup fails after the stack is acquired", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const isExisting of [false, true]) { + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-start-startup-failure-", + }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "startup-failure"\n', + ); + const fixture = fakeStack( + Effect.fail( + new StackError({ + operation: "composition.start", + message: "Composition start had failures", + }), + ), + ); + const error = yield* Effect.scoped( + stackStart(flags()).pipe( + Effect.flip, + Effect.provide(layers(root, fixture, mockOutput(), isExisting)), + ), + ); + expect(error).toBeInstanceOf(StackCommandStartError); + expect(fixture.hostStopped).toBe(1); + expect(fixture.hostDestroyed).toBe(0); + } + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 63c1ff31e0..9217298755 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -9,7 +9,9 @@ migration-history table to match. When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, -then destroys its namespace when the Effect scope closes. Stack shadows use the stack baseline +then destroys its namespace when the Effect scope closes. If its container engine is unreachable +then, the namespace is still removed and stderr lists the commands that remove its engine +resources. Stack shadows use the stack baseline cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root diff --git a/packages/stack/README.md b/packages/stack/README.md index 81b0aa4cce..8ab0eb2c0c 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -63,6 +63,10 @@ On Linux, native Functions project files must be outside `/tmp`: Edge Runtime us Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. +`create({ ..., startOwner: true })` registers the stack and then starts its owner immediately; if the owner fails to launch or the launch is interrupted, `create` removes the registration it just saved, unless an owner already holds the stack, and fails with the launch error. + +`destroy` normally returns `{ runtimeCleanup: "complete" }`. When no owner is running and the stack's container engine reports that its daemon cannot be reached, `destroy` removes the local registration and host data anyway and returns `{ runtimeCleanup: "skipped", engine, cleanupCommands }`; its containers and any database data in engine volumes remain, and `cleanupCommands` are the shell commands that remove them once the engine is running. If some host data cannot be deleted by the current user, `destroy` fails before removing anything so it can be retried with the engine running. + The stack owns database, Functions bootstrap, and tool-job directories below its data directory. Storage uploads remain at the caller-supplied Storage `filePath` and are preserved when the stack is destroyed; the caller owns that directory. Host metadata remains under `stateRoot`; native database data uses host files. Docker database data normally uses a managed volume, while existing host data is retained through the host-backed fallback. A host marker records the selected Docker storage and detects a missing or mismatched volume; deleting that volume loses the associated database data. Native snapshot entries live below `cacheRoot`. Docker snapshots share the managed data volume in a separate namespace derived from `cacheRoot`, so they survive source destruction and can use filesystem cloning. A Docker cache hit requires the same daemon, `stateRoot`, and `cacheRoot`. There is no portable tar snapshot API. Omitted database `jwtSecret` and `rootKey` inputs use the shared local-development values exported diff --git a/packages/stack/src/HostProcess.integration.test.ts b/packages/stack/src/HostProcess.integration.test.ts index 4f88e9c57a..b5bacdd389 100644 --- a/packages/stack/src/HostProcess.integration.test.ts +++ b/packages/stack/src/HostProcess.integration.test.ts @@ -20,7 +20,7 @@ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration verifies exact-port reopening. import * as Net from "node:net"; import { fileURLToPath } from "node:url"; -import { HostEndpoint, connectHost, launchHost } from "./HostProcess.ts"; +import { HostEndpoint, connectHost, controlPortHeld, launchHost } from "./HostProcess.ts"; import * as State from "./State.ts"; class ProcessTestError extends Data.TaggedError("ProcessTestError")<{ readonly message: string }> {} @@ -357,3 +357,28 @@ it.live("terminates a detached child when readiness is interrupted", () => }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.live("reports a stack's control port as held while a listener binds it", () => + Effect.gen(function* () { + const listener = yield* Effect.acquireRelease(bindExact(0), closeServer); + const address = listener.address(); + if (address === null || typeof address === "string") + return yield* new ProcessTestError({ message: "listener has no TCP address" }); + const saved: State.SavedStack = { + id: "stack", + runtime: "docker", + identity: { projectRoot: "/project", branchContext: "main", stackName: "local" }, + instances: [], + composition: { members: [], dependencies: [] }, + ports: [], + }; + + expect(yield* controlPortHeld(saved)).toBe(false); + expect( + yield* controlPortHeld({ + ...saved, + ports: [{ key: "control", host: "127.0.0.1", port: address.port }], + }), + ).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 51a3031395..2a7ed88376 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -1,5 +1,5 @@ import { NodeHttpServer } from "@effect/platform-node"; -import { Data, Effect, Duration, Option, Schedule, Schema, Scope, Stream } from "effect"; +import { Data, Effect, Duration, Exit, Option, Schedule, Schema, Scope, Stream } from "effect"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -22,6 +22,7 @@ type HostFailureReason = | "missing-control-listener" | "connection-failure" | "bind-conflict" + | "runtime-unavailable" | "invalid-owner-pid" | "owner-exit-pending" | "owner-exit-probe"; @@ -85,6 +86,22 @@ export const acquireHost = Effect.fn("HostProcess.acquireHost")(function* ( }; }); +/** + * Reports whether another process holds the stack's saved control port. An owner binds it under + * the state lock before it runs startup, so a caller holding that lock sees every owner that can + * still act on the stack. + */ +export const controlPortHeld = Effect.fn("HostProcess.controlPortHeld")(function* ( + stack: State.SavedStack, +) { + const claim = stack.ports.find((entry) => entry.key === "control"); + if (claim === undefined) return false; + const probe = yield* Effect.scoped( + NodeHttpServer.make(() => Http.createServer(), { host: claim.host, port: claim.port }), + ).pipe(Effect.exit); + return Exit.isFailure(probe); +}); + const endpointFromResponse = Effect.fn("HostProcess.endpointFromResponse")(function* ( state: State.Interface, stackId: string, @@ -151,7 +168,7 @@ const readyLine = Schema.Union([ Schema.Struct({ type: Schema.Literal("error"), message: Schema.String, - reason: Schema.optionalKey(Schema.Literal("bind-conflict")), + reason: Schema.optionalKey(Schema.Literals(["bind-conflict", "runtime-unavailable"])), }), ]); const spawnDetached = Effect.fn("HostProcess.spawnDetached")(function* ( diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 44c1c5b4e1..f82b331397 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -45,13 +45,15 @@ export class StackHostError extends Data.TaggedError("StackHostError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + readonly reason?: "runtime-unavailable"; }> {} -const hostError = (operation: string, cause: unknown) => +const hostError = (operation: string, cause: unknown, reason?: "runtime-unavailable") => new StackHostError({ operation, message: cause instanceof Error ? cause.message : String(cause), cause, + ...(reason === undefined ? {} : { reason }), }); const stackError = (operation: string, cause: unknown): StackError => { @@ -455,7 +457,15 @@ export const runStackHost = Effect.fn("StackHost.run")( engine: saved.runtime, stackId: saved.id, root: dataRoot, - }).pipe(Effect.mapError((cause) => hostError("startup-cleanup", cause))); + }).pipe( + Effect.mapError((cause) => + hostError( + "startup-cleanup", + cause, + cause.reason === "engine-unavailable" ? "runtime-unavailable" : undefined, + ), + ), + ); const services = yield* Layer.build( Layer.merge( Owner.layer({ diff --git a/packages/stack/src/create.owner-rollback.integration.test.ts b/packages/stack/src/create.owner-rollback.integration.test.ts new file mode 100644 index 0000000000..34ab4ff186 --- /dev/null +++ b/packages/stack/src/create.owner-rollback.integration.test.ts @@ -0,0 +1,101 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, Fiber, FileSystem, Layer, Path } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { create, discover } from "./effect.ts"; +import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; + +const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + +it.live( + "removes a stack it just registered when the owner fails to launch, and lets a retry succeed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-create-rollback-" }); + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, "docker"); + yield* fs.writeFileString( + dockerShim, + "#!/bin/sh\necho 'docker daemon unreachable' >&2\nexit 1\n", + ); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters[0]; + const identity = yield* resolveStackIdentity(options); + const id = yield* deriveStackId(identity); + + const launchFailure = yield* Effect.flip(create({ ...options, startOwner: true })); + expect(launchFailure.message).toContain("docker daemon unreachable"); + + expect(yield* discover({ stateRoot: options.stateRoot })).toEqual([]); + const stackDirExit = yield* Effect.exit(fs.access(path.join(options.stateRoot, id))); + expect(Exit.isFailure(stackDirExit)).toBe(true); + + const retried = yield* create({ ...options, runtime: "native" }); + expect(retried.id).toBe(id); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack it just registered when its owner launch is interrupted", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-create-interrupt-" }); + // The shim signals through a FIFO once the owner's startup sweep is running, then blocks. + const started = path.join(root, "sweep-started"); + yield* Effect.scoped( + spawner + .spawn(ChildProcess.make("mkfifo", [started])) + .pipe(Effect.flatMap((child) => child.exitCode)), + ); + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, "docker"); + yield* fs.writeFileString( + dockerShim, + `#!/bin/sh\nif [ "$1" = "ps" ]; then echo started > '${started}'; exec sleep 60; fi\nexit 0\n`, + ); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters[0]; + const creating = yield* create({ ...options, startOwner: true }).pipe(Effect.forkChild); + yield* fs.readFileString(started); + yield* Fiber.interrupt(creating); + + expect(yield* discover({ stateRoot: options.stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/destroy.runtime-unavailable.integration.test.ts b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts new file mode 100644 index 0000000000..595b75a6a5 --- /dev/null +++ b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts @@ -0,0 +1,311 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { create, discover } from "./effect.ts"; + +const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + +const shimDocker = Effect.fn("shimDocker")(function* ( + root: string, + script: string, + engine: "docker" | "podman" = "docker", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, engine); + yield* fs.writeFileString(dockerShim, script); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); +}); + +it.live( + "removes a stack's registration and data when destroy finds no owner and its engine is unreachable", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-offline-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n", + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters[0]; + const stack = yield* create(options); + const dataRoot = `${options.stateRoot}/${stack.id}/data`; + yield* fs.makeDirectory(`${dataRoot}/db-instance`, { recursive: true }); + yield* fs.writeFileString( + `${dataRoot}/db-instance/.supabase-database-storage.json`, + `{"backend":"docker","volume":"supabase-db-0123456789abcdef","namespace":"instance-${stack.id}-db-instance","cacheNamespace":"cache-${"0".repeat(32)}","daemonId":"daemon","initialized":true}`, + ); + const resolvedDataRoot = yield* fs.realPath(dataRoot); + + const result = yield* stack.destroy; + expect(result).toEqual({ + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [ + `sh -c 'ids=$(docker ps --all --quiet --no-trunc --filter '\\''label=com.supabase.stack=${stack.id}'\\'' --filter '\\''label=com.supabase.stack-root=${resolvedDataRoot}'\\'') && { [ -z "$ids" ] || docker rm --force $ids; }'`, + expect.stringMatching( + new RegExp( + `^docker run --rm --mount 'type=volume,src=supabase-db-0123456789abcdef,dst=/store' '[^']+' /bin/sh -c 'rm -rf /store/instance-${stack.id}-db-instance'$`, + "u", + ), + ), + ], + }); + + expect(yield* discover({ stateRoot: options.stateRoot })).toEqual([]); + const stackDirExit = yield* Effect.exit(fs.access(`${options.stateRoot}/${stack.id}`)); + expect(Exit.isFailure(stackDirExit)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack offline when Windows reports its Docker daemon pipe is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-windows-" }); + yield* shimDocker( + root, + `#!/bin/sh\necho 'error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/containers/json": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.' >&2\nexit 1\n`, + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + + const result = yield* stack.destroy; + + expect(result.runtimeCleanup).toBe("skipped"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack offline when its engine CLI is not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-no-cli-" }); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + yield* fs.makeDirectory(`${root}/empty-bin`); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${root}/empty-bin`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const result = yield* stack.destroy; + + expect(result.runtimeCleanup).toBe("skipped"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack offline when Docker reports its API socket is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-missing-socket-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'failed to connect to the docker API at unix:///tmp/missing-docker.sock; check if the path is correct and if the daemon is running: dial unix /tmp/missing-docker.sock: connect: no such file or directory' >&2\nexit 1\n", + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + + const result = yield* stack.destroy; + + expect(result.runtimeCleanup).toBe("skipped"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live( + "prints a container cleanup command that fails while the engine is down and removes every listed container once it is back", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-command-" }); + const unreachable = + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n"; + yield* shimDocker(root, unreachable); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + const result = yield* stack.destroy; + if (result.runtimeCleanup !== "skipped") return yield* Effect.die("cleanup was not skipped"); + const [containerCommand] = result.cleanupCommands; + if (containerCommand === undefined) return yield* Effect.die("container command missing"); + const run = Effect.scoped( + spawner + .spawn(ChildProcess.make("/bin/sh", ["-c", containerCommand])) + .pipe(Effect.flatMap((child) => child.exitCode)), + ); + + expect(Number(yield* run)).not.toBe(0); + yield* fs.writeFileString( + `${root}/bin/docker`, + '#!/bin/sh\nif [ "$1" = "ps" ]; then exit 0; fi\necho "docker rm requires at least 1 argument" >&2\nexit 1\n', + ); + expect(Number(yield* run)).toBe(0); + // Removal succeeds only when both listed IDs arrive as separate arguments. + yield* fs.writeFileString( + `${root}/bin/docker`, + '#!/bin/sh\nif [ "$1" = "ps" ]; then printf "aaa111\\nbbb222\\n"; exit 0; fi\nif [ "$1" = "rm" ] && [ "$2" = "--force" ] && [ "$#" -eq 4 ] && [ "$3" = "aaa111" ] && [ "$4" = "bbb222" ]; then exit 0; fi\necho "unexpected arguments: $*" >&2\nexit 1\n', + ); + expect(Number(yield* run)).toBe(0); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("keeps a stack registered when Podman rejects its credentials", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-podman-auth-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM' >&2\necho 'Error: unable to connect to Podman socket: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain' >&2\nexit 125\n", + "podman", + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "podman", + }); + + const destroyFailure = yield* Effect.flip(stack.destroy); + + expect(destroyFailure.message).toContain("unable to authenticate"); + expect(yield* discover({ stateRoot })).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live.skipIf(process.getuid?.() === 0)( + "keeps a stack and its data when offline destroy cannot remove container-owned host data", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-host-data-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n", + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + // Stands in for PostgreSQL files a container wrote as its own user. + const instanceRoot = `${stateRoot}/${stack.id}/data/db-instance`; + yield* fs.makeDirectory(`${instanceRoot}/data`, { recursive: true }); + yield* fs.writeFileString(`${instanceRoot}/owned-file`, "owned data"); + yield* fs.chmod(`${instanceRoot}/data`, 0o000); + yield* Effect.addFinalizer(() => fs.chmod(`${instanceRoot}/data`, 0o755).pipe(Effect.ignore)); + + const destroyFailure = yield* Effect.flip(stack.destroy); + + expect(destroyFailure.message).toContain("start Docker and run destroy again"); + expect(yield* discover({ stateRoot })).toHaveLength(1); + expect(yield* fs.exists(`${instanceRoot}/owned-file`)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("keeps a stack registered when its container engine rejects the listing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-permission-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock' >&2\nexit 1\n", + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters[0]; + const stack = yield* create(options); + + const destroyFailure = yield* Effect.flip(stack.destroy); + expect(destroyFailure.message).toContain("permission denied"); + + expect(yield* discover({ stateRoot: options.stateRoot })).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("keeps a stack registered when its container engine is reachable but cleanup fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-cleanup-failure-" }); + yield* shimDocker( + root, + [ + "#!/bin/sh", + 'if [ "$1" = "ps" ]; then', + ' echo "abc123def456"', + " exit 0", + "fi", + 'if [ "$1" = "rm" ]; then', + " echo 'docker: Error response from daemon: container removal failed' >&2", + " exit 1", + "fi", + "exit 0", + "", + ].join("\n"), + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters[0]; + const stack = yield* create(options); + + const destroyFailure = yield* Effect.flip(stack.destroy); + expect(destroyFailure.message).toContain("container removal failed"); + + expect(yield* discover({ stateRoot: options.stateRoot })).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index dfe5b48fcc..2c35b17b8c 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -4,10 +4,12 @@ import { Deferred, Effect, Exit, + FileSystem, Fiber, Layer, Match, Option, + Path, Ref, Scope, Schema, @@ -17,7 +19,15 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; -import { connectHost, launchHost, waitForOwnerExit } from "./HostProcess.ts"; +import { + connectHost, + controlPortHeld, + HostProcessError, + launchHost, + waitForOwnerExit, +} from "./HostProcess.ts"; +import { removeStackContainersCommand } from "./runtime/Container.ts"; +import { volumeDataCleanupCommands } from "./storage/DockerDatabaseStorage.ts"; import type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; import * as State from "./State.ts"; @@ -75,6 +85,7 @@ export interface CreateOptions extends StackLocations { readonly projectRoot: string; readonly name?: string; readonly runtime: "native" | "docker" | "podman"; + readonly startOwner?: boolean; } /** Opens a previously registered stack. */ export interface OpenOptions extends StackLocations { @@ -130,6 +141,19 @@ export type ServiceInstances = { [K in Kind]: K extends "database" ? DatabaseInstance : ServiceInstance; }; type AnyInstance = ServiceInstances[Kind]; +/** + * The outcome of {@link Stack.destroy}. `skipped` means the stack's registration and host data + * were removed without its container engine, because the engine was unreachable; its containers + * and any database data in engine volumes remain, and `cleanupCommands` remove them once the + * engine is running. + */ +export type DestroyResult = + | { readonly runtimeCleanup: "complete" } + | { + readonly runtimeCleanup: "skipped"; + readonly engine: "docker" | "podman"; + readonly cleanupCommands: ReadonlyArray; + }; /** An attached finite command with backpressured byte streams. */ export interface ToolOptions { readonly args?: ReadonlyArray; @@ -164,7 +188,7 @@ export interface Stack { readonly restart: Effect.Effect, StackError>; }; readonly stop: Effect.Effect; - readonly destroy: Effect.Effect; + readonly destroy: Effect.Effect; readonly tools: { readonly run: ( tool: PostgresTool, @@ -191,6 +215,8 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( const http = yield* HttpClient.HttpClient; const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const endpointFor = (live: boolean) => (live ? launchHost(state, { ...locations, stackId: saved.id }) @@ -217,19 +243,91 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( Stream.unwrap(Effect.map(client(false), run)).pipe( Stream.mapError((cause) => failure(operation, cause)), ); + /** + * Removes the stack's registration and host data without its owner, for a destroy that finds + * no owner running and can't start one because its container engine is unreachable. Its + * engine resources remain, and the result carries the commands that remove them. + */ + const firstUnremovableDirectory = (directory: string): Effect.Effect => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(directory).pipe(Effect.option); + const writable = yield* fs.access(directory, { writable: true }).pipe(Effect.isSuccess); + if (Option.isNone(entries) || !writable) return directory; + for (const entry of entries.value) { + const child = path.join(directory, entry); + const info = yield* fs.stat(child).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "Directory") continue; + if (yield* fs.readLink(child).pipe(Effect.isSuccess)) continue; + const blocked = yield* firstUnremovableDirectory(child); + if (blocked !== undefined) return blocked; + } + return undefined; + }); + const offlineDestroy = Effect.fn("Stack.offlineDestroy")(function* (engine: "docker" | "podman") { + const dataRoot = path.join(locations.stateRoot, saved.id, "data"); + return yield* state + .withLock( + Effect.gen(function* () { + const current = yield* state.read(saved.id); + if (current !== undefined && (yield* controlPortHeld(current))) + return yield* failure( + "destroy", + "An owner for this stack started during destroy; run destroy again", + ); + // Container-written host data, such as database files below Docker 26, can belong to the + // container user; only the engine can delete it, so refuse before deleting anything. + const blocked = + current !== undefined && (yield* fs.exists(dataRoot)) + ? yield* firstUnremovableDirectory(dataRoot) + : undefined; + if (blocked !== undefined) { + const engineName = engine === "docker" ? "Docker" : "Podman"; + return yield* failure( + "destroy", + `Stack data at ${blocked} can only be removed by ${engineName}; start ${engineName} and run destroy again`, + ); + } + // Containers are labelled with the resolved data root the owner ran with. + const root = yield* fs.realPath(dataRoot).pipe(Effect.orElseSucceed(() => dataRoot)); + const cleanupCommands = [ + removeStackContainersCommand({ engine, stackId: saved.id, root }), + ...(yield* volumeDataCleanupCommands({ engine, root, fs, path })), + ]; + if (current !== undefined) { + yield* fs.remove(dataRoot, { recursive: true, force: true }); + yield* state.remove(saved.id); + } + return { runtimeCleanup: "skipped", engine, cleanupCommands } as const; + }), + ) + .pipe(Effect.mapError((cause) => failure("destroy", cause))); + }); + const shutdown = Effect.fn("Stack.shutdown")(function* (destroy: boolean) { const operation = destroy ? "destroy" : "shutdown"; - const { endpoint, shutdownExit } = yield* Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* endpointFor(destroy); - const shutdownExit = yield* clientFor(endpoint.port).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.flatMap((rpc) => rpc.shutdown({ destroy })), - Effect.exit, - ); - return { endpoint, shutdownExit }; - }), - ).pipe(Effect.mapError((cause) => failure(operation, cause))); + const endpointExit = yield* Effect.scoped(endpointFor(destroy)).pipe(Effect.exit); + if (Exit.isFailure(endpointExit)) { + const endpointFailure = Option.getOrUndefined(Cause.findErrorOption(endpointExit.cause)); + if ( + destroy && + saved.runtime !== "native" && + endpointFailure instanceof HostProcessError && + endpointFailure.reason === "runtime-unavailable" + ) + return yield* offlineDestroy(saved.runtime); + return yield* Option.match(Cause.findErrorOption(endpointExit.cause), { + onNone: () => Effect.fail(failure(operation, Cause.pretty(endpointExit.cause))), + onSome: (cause) => Effect.fail(failure(operation, cause)), + }); + } + const endpoint = endpointExit.value; + const shutdownExit = yield* Effect.scoped( + clientFor(endpoint.port).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.flatMap((rpc) => rpc.shutdown({ destroy })), + Effect.exit, + ), + ); if (Exit.isFailure(shutdownExit)) { const shutdownFailure = Option.match(Cause.findErrorOption(shutdownExit.cause), { onNone: () => failure(operation, Cause.pretty(shutdownExit.cause)), @@ -258,6 +356,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( yield* waitForOwnerExit(endpoint.pid).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), ); + return { runtimeCleanup: "complete" } as const; }); const common = (id: string, service: K): ServiceInstance => ({ @@ -467,7 +566,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( stop: call("stopComposition", (rpc) => rpc.stopComposition()), restart: call("restartComposition", (rpc) => rpc.restartComposition()), }, - stop: shutdown(false), + stop: shutdown(false).pipe(Effect.asVoid), destroy: shutdown(true), tools: { run }, } satisfies Stack; @@ -494,6 +593,34 @@ export const create = Effect.fn("Stack.create")( yield* state.save(saved); }), ); + // Keeps the stack when an owner holds it: one that reported ready before an interrupt, or one + // another caller launched after this registration was saved. + const rollback = state.withLock( + Effect.gen(function* () { + const current = yield* state.read(id); + if (current !== undefined && !(yield* controlPortHeld(current))) yield* state.remove(id); + }), + ); + if (options.startOwner) + yield* Effect.scoped(launchHost(state, { ...options, stackId: id })).pipe( + Effect.onInterrupt(() => rollback.pipe(Effect.ignore)), + Effect.matchEffect({ + onFailure: (launchError) => + rollback.pipe( + Effect.matchEffect({ + onFailure: (removeError) => + Effect.fail( + failure( + "create", + `${failure("create", launchError).message}; failed to remove stack ${id} after startup failure: ${failure("create", removeError).message}`, + ), + ), + onSuccess: () => Effect.fail(failure("create", launchError)), + }), + ), + onSuccess: () => Effect.void, + }), + ); return yield* makeHandle(state, saved, options); }, Effect.mapError((cause) => failure("create", cause)), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 7b154fd130..0ecb803f34 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -50,7 +50,7 @@ export type { CompositionConfig } from "./Orchestrator.ts"; export type { Observation } from "./Rpc.ts"; export type { PgProveOptions } from "./effect.ts"; export type { SupabaseCompositionOptions } from "./effect.ts"; -export type { CreateOptions, OpenOptions, StackLocations } from "./effect.ts"; +export type { CreateOptions, DestroyResult, OpenOptions, StackLocations } from "./effect.ts"; const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); type Runtime = ReturnType; diff --git a/packages/stack/src/internal/host-process.ts b/packages/stack/src/internal/host-process.ts index c2b439775e..b70be51a23 100644 --- a/packages/stack/src/internal/host-process.ts +++ b/packages/stack/src/internal/host-process.ts @@ -74,10 +74,15 @@ const program = (args: ReadonlyArray) => yield* runStackHost(host).pipe( Effect.catchCause((cause) => { const failure = Option.getOrUndefined(Cause.findErrorOption(cause)); - const reason = causeCode(failure?.cause) === "EADDRINUSE" ? "bind-conflict" : undefined; + const reason = + causeCode(failure?.cause) === "EADDRINUSE" + ? "bind-conflict" + : failure?.reason === "runtime-unavailable" + ? "runtime-unavailable" + : undefined; return report({ type: "error", - message: String(cause), + message: failure?.message ?? Cause.pretty(cause), ...(reason === undefined ? {} : { reason }), }).pipe(Effect.exit, Effect.andThen(Effect.failCause(cause))); }), diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index f421a2b2c5..73e41ed6be 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -9,6 +9,7 @@ import { Fiber, Option, Path, + PlatformError, Ref, Schedule, Schema, @@ -22,6 +23,7 @@ export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + readonly reason?: "engine-unavailable"; }> {} interface ContainerSpec { @@ -86,6 +88,21 @@ const errorFor = (operation: string, cause: unknown) => const rateLimited = (error: ContainerError) => /toomanyrequests|too many requests|rate limit|rate exceeded/iu.test(error.message); +/** + * Matches an engine CLI that is missing or reports a daemon that is not listening, not one that + * rejects the caller. Podman's connection wrappers and Windows' `error during connect` also wrap + * authentication and TLS failures, so only their refused or missing-endpoint causes match. + */ +const engineUnreachable = (error: ContainerError) => + (error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" && + error.cause.reason.method === "spawn") || + /cannot connect to the docker daemon|connection refused|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified)/iu.test( + error.message, + ); + +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + const PULL_MAX_RETRIES = 4; const pullBackoff = Schedule.exponential("2 seconds").pipe(Schedule.jittered); @@ -555,7 +572,22 @@ export const removeStackContainers = Effect.fn("Container.removeStackContainers" `label=com.supabase.stack-root=${stackRoot}`, ]; const list = () => run(["ps", "--all", "--quiet", "--no-trunc", ...filters]); - const ids = (yield* list()).split("\n").filter((id) => id.length > 0); + // Only the initial listing can show the engine itself is unreachable; a later `rm` or + // leftover check failing is a per-container cleanup problem instead. + const ids = (yield* list().pipe( + Effect.mapError((cause) => + engineUnreachable(cause) + ? new ContainerError({ + operation: cause.operation, + message: cause.message, + cause: cause.cause, + reason: "engine-unavailable", + }) + : cause, + ), + )) + .split("\n") + .filter((id) => id.length > 0); yield* Effect.forEach( ids, (id) => @@ -575,3 +607,19 @@ export const removeStackContainers = Effect.fn("Container.removeStackContainers" return yield* errorFor("cleanup", `Stack containers remain: ${remaining}`); }), ); + +/** Shell command that removes the same containers as `removeStackContainers`, succeeding when none remain. */ +export const removeStackContainersCommand = (options: { + readonly engine: "docker" | "podman"; + readonly stackId: string; + readonly root: string; +}): string => { + const filters = [ + `label=com.supabase.stack=${options.stackId}`, + `label=com.supabase.stack-root=${options.root}`, + ] + .map((filter) => `--filter ${shellQuote(filter)}`) + .join(" "); + // `sh -c` keeps POSIX word splitting of `$ids` when pasted into shells like zsh that skip it. + return `sh -c ${shellQuote(`ids=$(${options.engine} ps --all --quiet --no-trunc ${filters}) && { [ -z "$ids" ] || ${options.engine} rm --force $ids; }`)}`; +}; diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 045426f882..116eb851bb 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -1124,3 +1124,43 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }; }), ); + +/** Shell commands that delete the Docker-volume data recorded by the database instances under a stack's data root. */ +export const volumeDataCleanupCommands = Effect.fn( + "DockerDatabaseStorage.volumeDataCleanupCommands", +)( + function* (options: { + readonly engine: "docker" | "podman"; + readonly root: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }) { + if (!(yield* options.fs.exists(options.root))) return []; + const markers: Array = []; + for (const entry of yield* options.fs.readDirectory(options.root)) { + const markerPath = options.path.join(options.root, entry, ".supabase-database-storage.json"); + if (!(yield* options.fs.exists(markerPath))) continue; + const marker = yield* options.fs + .readFileString(markerPath) + .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(Marker)))); + if (marker.backend !== "docker") continue; + if ( + marker.volume === undefined || + !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/u.test(marker.volume) || + !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(marker.namespace) + ) + return yield* errorFor( + "destroy", + `Recorded database storage identity is invalid: ${markerPath}`, + ); + markers.push(marker); + } + if (markers.length === 0) return []; + const { image } = yield* resolveArtifact({ service: "database" }); + return markers.map( + (marker) => + `${options.engine} run --rm --mount ${shellQuote(`type=volume,src=${marker.volume},dst=/store`)} ${shellQuote(image)} /bin/sh -c ${shellQuote(`rm -rf /store/${marker.namespace}`)}`, + ); + }, + Effect.mapError((cause) => errorFor("destroy", cause)), +); From b8026df5ea50d49bc06dcfa8ab877e5f92c032a9 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:36:25 +0000 Subject: [PATCH 03/71] fix(stack): require passwords on the native database socket (CLI-2503) (#6833) ## TL;DR fixes `dblink` connections as `postgres` on the native stack and makes its database port check passwords like Docker. ## whats broken? the native postgres only listens on its unix socket, and the bundled `pg_hba.conf` trusts every local connection. so `dblink` as `postgres` was refused for not using a password, and the database port accepted any password. ## now fixed by: the native launch writes its own hba file into the socket directory and points `hba_file` at it. local connections now authenticate with `scram-sha-256`, except `supabase_admin` which the stack uses to bootstrap roles. a wrong password on the native database port now fails, as it does on Docker.. ## ref: - closes: https://github.com/supabase/cli/issues/6832 --- apps/cli/docs/stack-commands.md | 4 ++ packages/stack/README.md | 2 + packages/stack/src/runtime/postgres-user.ts | 5 +- .../src/services/Database.integration.test.ts | 71 ++++++++++++++++++- packages/stack/src/services/Database.ts | 31 +++++--- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 3ddf1f803c..4ce21f7632 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -5,6 +5,10 @@ command interface may change, and it is excluded from the CLI compatibility prom available when the `experimental.stack` feature flag is enabled and supports both Docker and native runtimes. +Native PostgreSQL requires passwords for every role except `supabase_admin`. Its local bootstrap +and password-reconciliation connection uses that administrative role, so a native +`supabase_admin` connection is not password-checked. + | Command | Purpose | | ------------------------ | --------------------------------------------------------------------------------- | | `supabase stack destroy` | Permanently delete one stack and its data. | diff --git a/packages/stack/README.md b/packages/stack/README.md index 8ab0eb2c0c..f0cd1a40a7 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -75,6 +75,8 @@ The effective root key is supplied through a stack-owned file for both native an Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code), or `SUPABASE_NATIVE_POSTGRES_USER=` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. +Native PostgreSQL listens only on its socket and reads a stack-generated HBA file, written to the socket directory on every launch, instead of `PGDATA/pg_hba.conf`. It trusts `supabase_admin`, including through the proxied loopback database port, and requires `scram-sha-256` passwords from every other role. + ## Composition and operation scope Registering a service does not add it to the application composition. For a fresh composition, `composition.supabase` registers the selected recipes and supplies their standard bindings: diff --git a/packages/stack/src/runtime/postgres-user.ts b/packages/stack/src/runtime/postgres-user.ts index 3f41b876bf..b8019c81c9 100644 --- a/packages/stack/src/runtime/postgres-user.ts +++ b/packages/stack/src/runtime/postgres-user.ts @@ -183,13 +183,14 @@ export const openNativePostgresInstance = Effect.fn("NativePostgresUser.openInst /** The bundle's first-boot init runs `chmod +x` on this script, which only its owner may do. */ const GETKEY_SCRIPT = "share/supabase-cli/config/pgsodium_getkey.sh"; -/** Hands the instance data, key, socket, and the bundle's getkey script to the PostgreSQL user. */ +/** Hands the instance data, key, socket, HBA file, and the bundle's getkey script to the PostgreSQL user. */ export const handOverNativePostgresFiles = Effect.fn("NativePostgresUser.handOverFiles")(function* ( user: PasswdEntry, paths: { readonly dataPath: string; readonly rootKeyPath: string; readonly socketPath: string; + readonly hbaPath: string; readonly bundleRoot: string; readonly executable: string; }, @@ -199,7 +200,7 @@ export const handOverNativePostgresFiles = Effect.fn("NativePostgresUser.handOve const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const getkey = path.join(paths.bundleRoot, GETKEY_SCRIPT); const hasGetkey = yield* fs.exists(getkey); - const targets = [paths.dataPath, paths.rootKeyPath, paths.socketPath]; + const targets = [paths.dataPath, paths.rootKeyPath, paths.socketPath, paths.hbaPath]; if (hasGetkey) { yield* requireOwnedByRootOr(user, getkey); targets.push(getkey); diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index a788390923..21010b3712 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -1,7 +1,18 @@ import { PgClient } from "@effect/sql-pg"; import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Context, Deferred, Effect, FileSystem, Layer, Path, Redacted, Ref, Stream } from "effect"; +import { + Context, + Deferred, + Effect, + FileSystem, + Layer, + Path, + Predicate, + Redacted, + Ref, + Stream, +} from "effect"; import { tmpdir } from "node:os"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; import { makeService } from "../Service.ts"; @@ -23,6 +34,7 @@ const query = ( password: Redacted.Redacted, statement: string, database = "postgres", + username = "supabase_admin", ) => Effect.scoped( Effect.gen(function* () { @@ -32,7 +44,7 @@ const query = ( host, port: endpoint.port, database, - username: "supabase_admin", + username, password, }), ); @@ -144,6 +156,61 @@ describe("database component", { timeout: 180_000 }, () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + it.live("requires passwords from non-superusers on the native socket", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-database-hba-" }); + const database = yield* makeDatabase({ + stackId: "stack-integration", + instanceId: "hba", + root, + cacheRoot: artifactCacheRoot, + runtime: "native", + }); + const service = yield* makeService(database.definition, { id: "database:hba", config }); + yield* service.start; + yield* service.ready; + const endpoint = yield* database.endpoint; + expect( + yield* query( + endpoint, + config.databasePassword, + "SELECT rolsuper FROM pg_roles WHERE rolname = 'postgres'", + ), + ).toEqual([{ rolsuper: false }]); + const rejected = yield* query( + endpoint, + Redacted.make("wrong-password"), + "SELECT 1", + "postgres", + "postgres", + ).pipe(Effect.flip); + expect(Predicate.isTagged(rejected.reason, "AuthenticationError")).toBe(true); + yield* query( + endpoint, + config.databasePassword, + "CREATE EXTENSION dblink; CREATE ROLE dblink_probe LOGIN PASSWORD 'probe-password'", + ); + const connected = yield* query( + endpoint, + config.databasePassword, + "SELECT dblink_connect(format('host=%s port=%s dbname=postgres user=dblink_probe password=probe-password', current_setting('unix_socket_directories'), current_setting('port'))) AS status", + "postgres", + "postgres", + ); + expect(connected).toEqual([{ status: "OK" }]); + const rotated = Redacted.make("rotated-password"); + yield* service.restart({ ...config, databasePassword: rotated }); + yield* service.ready; + expect( + yield* query(yield* database.endpoint, rotated, "SELECT 1 AS ok", "postgres", "postgres"), + ).toEqual([{ ok: 1 }]); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + it.live( "persists SQL data across exact-session stop and reopen, isolates instances, and validates restart before stopping", () => diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index d8eb375d99..14c6e136d4 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -85,6 +85,9 @@ const DatabaseReadyMarker = Schema.Struct({ profile: Schema.Literal("supabase"), }); +// Health reconciles role passwords as supabase_admin, including after a configured password change. +const NATIVE_HBA_RULES = "local all supabase_admin trust\nlocal all all scram-sha-256\n"; + export interface DatabaseConfig extends Schema.Schema.Type {} export const DatabaseEndpoints = Schema.Struct({ sql: Schema.optionalKey(EndpointIntent) }); @@ -479,9 +482,12 @@ const removeOwnedRoot = Effect.fn("Database.removeOwnedRoot")(( const nativeProcess = ( artifact: PreparedNativeArtifact, config: DatabaseConfig, - dataPath: string, - socketPath: string, - rootKeyPath: string, + paths: { + readonly dataPath: string; + readonly socketPath: string; + readonly hbaPath: string; + readonly rootKeyPath: string; + }, settings: ReadonlyArray, context: ServiceInstanceContext, stackId: string, @@ -495,19 +501,21 @@ const nativeProcess = ( ...(user === undefined ? {} : { uid: user.uid, gid: user.gid, cwd: "/" }), args: [ "-D", - dataPath, + paths.dataPath, "-p", "5432", "-c", "listen_addresses=", "-c", - `unix_socket_directories=${socketPath}`, + `unix_socket_directories=${paths.socketPath}`, + "-c", + `hba_file=${paths.hbaPath}`, ...settings, ], env: { ...(user === undefined ? {} : { HOME: user.home }), - PGDATA: dataPath, - PGSODIUM_KEY_FILE: rootKeyPath, + PGDATA: paths.dataPath, + PGSODIUM_KEY_FILE: paths.rootKeyPath, POSTGRES_USER: "supabase_admin", POSTGRES_DB: "postgres", POSTGRES_PASSWORD: Redacted.value(config.databasePassword), @@ -883,6 +891,10 @@ export const makeDatabase = ( Scope.provide(context.scope), Effect.mapError((cause) => errorFor("launch", cause)), ); + const hbaPath = path.join(socketPath, "pg_hba.conf"); + yield* fs + .writeFileString(hbaPath, NATIVE_HBA_RULES, { mode: 0o600 }) + .pipe(Effect.mapError((cause) => errorFor("launch", cause))); const artifact = (yield* Ref.get(prepared)).get(config.version); if (artifact === undefined) return yield* errorFor("launch", `Artifact ${config.version} was not prepared`); @@ -892,6 +904,7 @@ export const makeDatabase = ( dataPath, rootKeyPath, socketPath, + hbaPath, bundleRoot: artifact.root, executable: artifact.executable, }), @@ -899,9 +912,7 @@ export const makeDatabase = ( const process = yield* nativeProcess( artifact, config, - dataPath, - socketPath, - rootKeyPath, + { dataPath, socketPath, hbaPath, rootKeyPath }, settings, context, String(options.stackId), From 643d648202ffcc71ed2fad95c17b92e822bcc205 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:25:56 +0000 Subject: [PATCH 04/71] fix(stack): start auth with zero session limits (CLI-2506) (#6848) ## TL;DR fixes auth refusing to start on the stack when `timebox` or `inactivity_timeout` is `0s` ## whats broken? the stack forwarded zero session limits to auth as is. auth rejects a zero duration, so it exited on boot and every auth request returned 502. ## now fixed by: zero session limits are left out of the auth environment, so `0s` means no limit. every other value passes through unchanged. ## ref: - closes: https://github.com/supabase/cli/issues/6845 --- packages/stack/src/services/AuthSettings.ts | 9 +++++++-- .../stack/src/services/AuthSettings.unit.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/services/AuthSettings.ts b/packages/stack/src/services/AuthSettings.ts index dfce740c25..9bf72c2e0d 100644 --- a/packages/stack/src/services/AuthSettings.ts +++ b/packages/stack/src/services/AuthSettings.ts @@ -159,6 +159,11 @@ export const Settings = Schema.Struct({ }); export interface Settings extends Schema.Schema.Type {} +const zeroDuration = /^[+-]?(?:0|(?:(?:0+(?:\.0*)?|\.0+)(?:ns|us|µs|μs|ms|s|m|h))+)$/u; + +const sessionLimit = (duration: string | undefined) => + zeroDuration.test(duration ?? "") ? undefined : duration; + /** Converts Auth policy into the upstream process configuration. */ export const settingsEnvironment = ( settings: Settings | undefined, @@ -265,8 +270,8 @@ export const settingsEnvironment = ( put("GOTRUE_MFA_PHONE_OTP_LENGTH", mfa.phone.otp_length); put("GOTRUE_MFA_PHONE_MAX_FREQUENCY", mfa.phone.max_frequency); } - put("GOTRUE_SESSIONS_TIMEBOX", settings.sessions?.timebox); - put("GOTRUE_SESSIONS_INACTIVITY_TIMEOUT", settings.sessions?.inactivity_timeout); + put("GOTRUE_SESSIONS_TIMEBOX", sessionLimit(settings.sessions?.timebox)); + put("GOTRUE_SESSIONS_INACTIVITY_TIMEOUT", sessionLimit(settings.sessions?.inactivity_timeout)); for (const [name, provider] of Object.entries(settings.external ?? {})) { const prefix = `GOTRUE_EXTERNAL_${name.toUpperCase()}`; put(`${prefix}_ENABLED`, provider.enabled); diff --git a/packages/stack/src/services/AuthSettings.unit.test.ts b/packages/stack/src/services/AuthSettings.unit.test.ts index c440e175e6..82ab1f8cc8 100644 --- a/packages/stack/src/services/AuthSettings.unit.test.ts +++ b/packages/stack/src/services/AuthSettings.unit.test.ts @@ -98,4 +98,20 @@ describe("Auth settings environment", () => { expect(env["GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED"]).toBeUndefined(); expect(env["GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION"]).toBeUndefined(); }); + + it("omits zero session limits and forwards every other value", () => { + const env = (sessions: { timebox: string; inactivity_timeout: string }) => + settingsEnvironment(Schema.decodeSync(Settings)({ sessions }), "https://issuer.example"); + + const unlimited = env({ timebox: "0s", inactivity_timeout: "0h0m0s" }); + const limited = env({ timebox: "24h", inactivity_timeout: "8h" }); + const invalid = env({ timebox: "never", inactivity_timeout: "-1h" }); + + expect(unlimited["GOTRUE_SESSIONS_TIMEBOX"]).toBeUndefined(); + expect(unlimited["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBeUndefined(); + expect(limited["GOTRUE_SESSIONS_TIMEBOX"]).toBe("24h"); + expect(limited["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBe("8h"); + expect(invalid["GOTRUE_SESSIONS_TIMEBOX"]).toBe("never"); + expect(invalid["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBe("-1h"); + }); }); From e29a71245659d7b9bd5b27197cb8611704cd368a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 07:45:57 +0000 Subject: [PATCH 05/71] fix(stack): upgrade edge-runtime to v1.77.1 (#6844) Co-authored-by: Claude Opus 5.5 --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index fa87292ca5..a9c85ee0ca 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -9,7 +9,7 @@ FROM postgrest/postgrest:v16.3 AS postgrest FROM supabase/postgres-meta:v0.99.0 AS pgmeta FROM supabase/studio:2026.09.14-sha-4dd8a95 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.76.2 AS edgeruntime +FROM supabase/edge-runtime:v1.77.1 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index fa87292ca5..a9c85ee0ca 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -9,7 +9,7 @@ FROM postgrest/postgrest:v16.3 AS postgrest FROM supabase/postgres-meta:v0.99.0 AS pgmeta FROM supabase/studio:2026.09.14-sha-4dd8a95 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.76.2 AS edgeruntime +FROM supabase/edge-runtime:v1.77.1 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 78b6d5032c..c36d99f2b0 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -104,8 +104,8 @@ const definitions: Readonly> = { ), functions: definition( "edge-runtime", - "v1.76.2", - "ghcr.io/supabase/cli/edge-runtime:v1.76.2", + "v1.77.1", + "ghcr.io/supabase/cli/edge-runtime:v1.77.1@sha256:db55555ba640180671be297179797ea39c8c6cf09a22a0b883923cb86938f5e1", "bin/edge-runtime", ), studio: definition( From f5c0a5d2e9be501bf2b96861eb3d23fb982ab423 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:46:56 +0000 Subject: [PATCH 06/71] chore(api): sync Management API OpenAPI spec (#6826) 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 | 25 ++++++++++++++++++++++--- packages/api/src/generated/openapi.json | 16 ++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 716e098581..73628b325e 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -5282,6 +5282,13 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), presence_enabled: Schema.Boolean.annotate({ description: "Whether to enable presence" }), + admin_suspended_at: Schema.Union([ + Schema.String.annotate({ + description: "If set, the Realtime service has been suspended by an admin.", + format: "date-time", + }), + Schema.Null, + ]), }); export const V1GetRestorePointInput = Schema.Struct({ ref: Schema.String.check( @@ -9914,7 +9921,13 @@ export const V2AssignOrganizationMemberRoleInput = Schema.Struct({ data: Schema.Struct({ type: Schema.Literal("organization_member_role").annotate({ description: "Resource type." }), attributes: Schema.Struct({ - role: Schema.Literals(["owner", "administrator", "developer", "read-only"]).annotate({ + role: Schema.Literals([ + "owner", + "administrator", + "developer", + "read-only", + "no-access", + ]).annotate({ description: "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", }), @@ -10711,9 +10724,15 @@ export const V2CreateOrganizationInvitationsInput = Schema.Struct({ "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", }), ), - role: Schema.Literals(["owner", "administrator", "developer", "read-only"]).annotate({ + role: Schema.Literals([ + "owner", + "administrator", + "developer", + "read-only", + "no-access", + ]).annotate({ description: - "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + "Role name to assign. Must be on an Enterprise plan to use the read-only or no-access roles. no-access grants no project visibility until project-scoped roles are assigned separately.", }), projects: Schema.optionalKey( Schema.Array( diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 5136f2fe06..945dc9b7a0 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -22826,6 +22826,13 @@ "presence_enabled": { "type": "boolean", "description": "Whether to enable presence" + }, + "admin_suspended_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "description": "If set, the Realtime service has been suspended by an admin.", + "nullable": true } }, "required": [ @@ -22840,7 +22847,8 @@ "max_presence_events_per_second", "max_payload_size_in_kb", "suspend", - "presence_enabled" + "presence_enabled", + "admin_suspended_at" ] }, "UpdateRealtimeConfigBody": { @@ -27850,7 +27858,7 @@ "properties": { "role": { "type": "string", - "enum": ["owner", "administrator", "developer", "read-only"], + "enum": ["owner", "administrator", "developer", "read-only", "no-access"], "description": "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", "example": "developer" }, @@ -27986,8 +27994,8 @@ }, "role": { "type": "string", - "enum": ["owner", "administrator", "developer", "read-only"], - "description": "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + "enum": ["owner", "administrator", "developer", "read-only", "no-access"], + "description": "Role name to assign. Must be on an Enterprise plan to use the read-only or no-access roles. no-access grants no project visibility until project-scoped roles are assigned separately.", "example": "developer" }, "projects": { From f70cda594448d35865549dcdc41ca16316bd8f37 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 07:51:48 +0000 Subject: [PATCH 07/71] fix(cli): read request bodies before local functions respond (#6830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR Local Edge Functions no longer turn early responses to uploads into `502 Bad Gateway`. ## whats introduced? When the functions main service answered before reading the request body, the runtime closed the connection with the body unread. The main service answers early either itself, for example a JWT `401` or a `404`, or when the function returns without reading the body. The gateway's next body write then hit a reset, and it replied `502`, discarding the response it had already received. - both functions bootstraps (`apps/cli` and `packages/stack`) now own the request body: the worker gets its own stream, and whatever it leaves unread is drained before the response is returned - early responses to requests with a body are now sent once the body has been received, which behind Kong was already the case - integration tests cover a worker that abandons the body and the bootstrap's own `401` and `404` rejections, checking that the body is read to the end and never cancelled - the offline e2e sends larger bodies for the early function rejection and the JWT rejection through Kong ## ref: - related: #6564 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../serve-main-bundler.integration.test.ts | 105 +++++++ .../functions/serve-main-offline.e2e.test.ts | 5 +- apps/cli/src/shared/functions/serve.main.ts | 50 +++- .../serve-main-bundler.integration.test.ts | 270 +++++++++++++----- packages/stack/src/functions/serve.main.ts | 35 ++- 5 files changed, 376 insertions(+), 89 deletions(-) diff --git a/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts index 342e83b1a3..bef3c5392a 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts @@ -79,6 +79,7 @@ const load = async ( AbortController, AbortSignal, Headers, + ReadableStream, Request, Response, URL, @@ -330,6 +331,110 @@ describe("CLI functions bootstrap bundle", () => { }); }); + describe("request body ownership", () => { + const upload = (chunks = 4) => { + const progress = { readToEnd: false, cancelled: false }; + let sent = 0; + const body = new ReadableStream({ + pull: (controller) => { + if (sent === chunks) { + progress.readToEnd = true; + controller.close(); + return; + } + sent += 1; + controller.enqueue(new Uint8Array(1024)); + }, + cancel: () => { + progress.cancelled = true; + }, + }); + return { body, progress }; + }; + const functionConfig = (verifyJWT: boolean) => + JSON.stringify({ + hello: { entrypointPath: "hello/index.ts", importMapPath: "", staticFiles: [], verifyJWT }, + }); + + it("reads the rest of a request body the worker abandons", async () => { + const loaded = await load(await bundleServeMainTemplate(), baseEnv(functionConfig(false)), { + fetch: async (request: Request) => { + const reader = request.body?.getReader(); + await reader?.read(); + // Not awaited: if the body were shared with the incoming request again, Bun + // would never settle this cancel and the test would hang instead of failing. + void reader?.cancel(); + return new Response("rejected", { status: 400 }); + }, + }); + const { body, progress } = upload(); + + const response = await loaded.options.handler( + new Request("http://localhost/hello", { method: "POST", body, duplex: "half" }), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(400); + expect(await response.text()).toBe("rejected"); + }); + + it("settles an aborted request while the abandoned body read is pending", async () => { + const { promise: readPending, resolve: markReadPending } = Promise.withResolvers(); + const pendingRead = Promise.withResolvers().promise; + let pulls = 0; + const body = new ReadableStream({ + pull: (streamController) => { + pulls += 1; + if (pulls === 1) { + streamController.enqueue(new Uint8Array([1])); + return; + } + markReadPending(); + return pendingRead; + }, + }); + const loaded = await load(await bundleServeMainTemplate(), baseEnv(functionConfig(false)), { + fetch: async () => new Response("worker should not run"), + }); + const controller = new AbortController(); + const pending = loaded.options.handler( + new Request("http://localhost/missing", { + method: "POST", + body, + duplex: "half", + signal: controller.signal, + }), + ); + + await readPending; + controller.abort(); + + await expect(pending).resolves.toMatchObject({ status: 499 }); + }); + + it.each([ + ["an invalid token", "/hello", 401], + ["an unknown function", "/missing", 404], + ] as const)("reads the whole upload before rejecting %s", async (_, path, status) => { + const loaded = await load(await bundleServeMainTemplate(), baseEnv(functionConfig(true)), { + fetch: async () => new Response("should not run"), + }); + const { body, progress } = upload(); + + const response = await loaded.options.handler( + new Request(`http://localhost${path}`, { + method: "POST", + body, + duplex: "half", + headers: { Authorization: "Bearer invalid" }, + }), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(status); + }); + }); + it("does not fetch after an aborted pending worker creation", async () => { const bundle = await bundleServeMainTemplate(); const config = JSON.stringify({ diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index a081624c56..39ea81a62e 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -593,7 +593,7 @@ describe("functions serve runtime template (offline)", () => { { method: "POST", headers: { "x-reject-before-body": "true" }, - body: new Uint8Array(128 * 1024), + body: new Uint8Array(1024 * 1024), signal: AbortSignal.timeout(5_000), }, ); @@ -606,7 +606,10 @@ describe("functions serve runtime template (offline)", () => { expect(runtimeLogs).not.toContain("must-not-appear-in-debug-logs"); const authResponse = await fetch(authUrl, { + method: "POST", headers: { Origin: "http://localhost:3000" }, + body: new Uint8Array(1024 * 1024), + signal: AbortSignal.timeout(5_000), }); expect(authResponse.status).toBe(401); expect(authResponse.headers.get("sb-error-code")).toBe("UNAUTHORIZED_NO_AUTH_HEADER"); diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index cbca57da80..bfcd1fd5c7 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -1,4 +1,15 @@ -import { Cause, Config, ConfigProvider, Console, Data, Effect, Exit, Option, Schema } from "effect"; +import { + Cause, + Config, + ConfigProvider, + Console, + Data, + Effect, + Exit, + Option, + Schema, + Stream, +} from "effect"; import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; import * as jose from "jose"; @@ -383,12 +394,33 @@ function shouldUsePackageJsonDiscovery({ ); } -export function prepareUserRequest(req: Request): Request { +interface RequestBodyReader { + read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>; +} + +const requestBodyChunks = (body: RequestBodyReader) => + Stream.fromEffectRepeat( + foreign(() => body.read()).pipe( + Effect.flatMap((chunk) => (chunk.done ? Cause.done() : Effect.succeed(chunk.value))), + ), + ); + +const drainRequestBody = (body: RequestBodyReader | undefined) => + body === undefined ? Effect.void : Stream.runDrain(requestBodyChunks(body)).pipe(Effect.ignore); + +export function prepareUserRequest(req: Request, body: RequestBodyReader | undefined): Request { const clonedURL = new URL(req.url); const forwardedHost = req.headers.get("x-forwarded-host"); clonedURL.hostname = forwardedHost ?? clonedURL.hostname; - // Cloning tees the body, so an unread branch can stall early worker responses. - const forwardedReq = new Request(clonedURL.href, req); + // The runtime closes a connection whose request body is unread, which gateways report as 502, so + // the worker gets its own stream and cancelling it leaves the body for `drainRequestBody`. + const forwardedReq = new Request(clonedURL.href, { + method: req.method, + headers: req.headers, + body: body === undefined ? null : Stream.toReadableStream(requestBodyChunks(body)), + signal: req.signal, + duplex: "half", + }); forwardedReq.headers.delete("sb-api-key"); EdgeRuntime.applySupabaseTag(req, forwardedReq); @@ -400,6 +432,12 @@ Deno.serve({ handler: (req: Request) => Effect.runPromiseExit( Effect.gen(function* () { + // `worker.fetch` settles its body pipe before resolving, so the drain only reads what the + // worker abandoned. + const body = yield* Effect.acquireRelease( + Effect.sync(() => req.body?.getReader()), + (body) => Effect.interruptible(drainRequestBody(body)), + ); const url = new URL(req.url); const { pathname } = url; @@ -510,7 +548,7 @@ Deno.serve({ }), ); - const userReq = prepareUserRequest(req); + const userReq = prepareUserRequest(req, body); return yield* foreign(() => worker.fetch(userReq)); }); @@ -527,7 +565,7 @@ Deno.serve({ ), ), ); - }), + }).pipe(Effect.scoped), { signal: req.signal }, ).then((exit) => { if (Exit.isSuccess(exit)) return exit.value; diff --git a/packages/stack/src/functions/serve-main-bundler.integration.test.ts b/packages/stack/src/functions/serve-main-bundler.integration.test.ts index aa14bd4570..f16a4149b9 100644 --- a/packages/stack/src/functions/serve-main-bundler.integration.test.ts +++ b/packages/stack/src/functions/serve-main-bundler.integration.test.ts @@ -16,6 +16,97 @@ class WorkerAlreadyRetired extends Error {} // oxlint-disable-next-line effecttsgo/extends-native-error -- stands in for Deno.errors.InvalidWorkerResponse, matched by instanceof at the sandbox boundary. class InvalidWorkerResponse extends Error {} +type TestWorker = { fetch(request: Request): Promise }; + +const serveSandboxed = (functionsConfig: string, createWorker: () => TestWorker) => + Effect.gen(function* () { + const envRecord: Record = { + SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", + SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfig, + }; + const bundled = yield* bundleServeMainTemplate; + let serveOptions: ServeOptions | undefined; + const sandbox = { + Deno: { + env: { + get: (name: string) => envRecord[name], + toObject: () => envRecord, + }, + errors: { WorkerAlreadyRetired, InvalidWorkerResponse }, + lstat: (path: string) => { + const isDirectory = path === "/functions" || path === "/functions/hello"; + const isFile = path === "/functions/hello/index.ts"; + return isDirectory || isFile + ? Promise.resolve({ isDirectory, isFile, isSymlink: false }) + : Promise.reject(new MissingFixture()); + }, + realPath: (path: string) => Promise.resolve(path), + readDir: () => Stream.toAsyncIterable(Stream.empty), + makeTempDirSync: () => "/tmp/worker", + version: { deno: "test" }, + serve: (options: ServeOptions) => { + serveOptions = options; + }, + }, + EdgeRuntime: { + applySupabaseTag: () => undefined, + userWorkers: { create: () => Promise.resolve(createWorker()) }, + }, + AbortController, + AbortSignal, + Headers, + ReadableStream, + Request, + Response, + URL, + console, + crypto, + CryptoKey, + Uint8Array, + ArrayBuffer, + atob, + btoa, + setTimeout, + clearTimeout, + TextEncoder, + TextDecoder, + structuredClone, + }; + const module = new SourceTextModule(bundled, { + context: createContext(sandbox), + identifier: "serve.main.sandboxed.bundle.js", + }); + yield* Effect.tryPromise(() => + module.link(() => { + throw new Error("Bundled service unexpectedly imported another module"); + }), + ); + yield* Effect.tryPromise(() => module.evaluate()); + if (serveOptions === undefined) + return yield* Effect.die("Bundled service did not register a server"); + return serveOptions.handler; + }); + +const upload = (chunks = 4) => { + const progress = { readToEnd: false, cancelled: false }; + let sent = 0; + const body = new ReadableStream({ + pull: (controller) => { + if (sent === chunks) { + progress.readToEnd = true; + controller.close(); + return; + } + sent += 1; + controller.enqueue(new Uint8Array(1024)); + }, + cancel: () => { + progress.cancelled = true; + }, + }); + return { body, progress }; +}; + describe("stack-owned functions bootstrap", () => { it.live("produces an executable offline service with the expected runtime contract", () => { const controller = new AbortController(); @@ -83,6 +174,7 @@ describe("stack-owned functions bootstrap", () => { }, }, AbortController, + ReadableStream, Request, Response, URL, @@ -374,90 +466,24 @@ describe("stack-owned functions bootstrap", () => { ); describe("retired worker dispatch", () => { - const envRecord: Record = { - SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", - SUPABASE_INTERNAL_FUNCTIONS_CONFIG: '{"hello":{"verifyJWT":false}}', - }; // Every create() hands out a distinct worker: worker n always rejects with failures[n - 1] when // one is given, otherwise it answers with its own number so the response names the worker. const serve = (failures: ReadonlyArray) => Effect.gen(function* () { - const bundled = yield* bundleServeMainTemplate; - let serveOptions: ServeOptions | undefined; let creates = 0; - const sandbox = { - Deno: { - env: { - get: (name: string) => envRecord[name], - toObject: () => envRecord, - }, - errors: { WorkerAlreadyRetired, InvalidWorkerResponse }, - lstat: (path: string) => { - const isDirectory = path === "/functions" || path === "/functions/hello"; - const isFile = path === "/functions/hello/index.ts"; - return isDirectory || isFile - ? Promise.resolve({ isDirectory, isFile, isSymlink: false }) - : Promise.reject(new MissingFixture()); - }, - realPath: (path: string) => Promise.resolve(path), - readDir: () => Stream.toAsyncIterable(Stream.empty), - makeTempDirSync: () => "/tmp/worker", - version: { deno: "test" }, - serve: (options: ServeOptions) => { - serveOptions = options; + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":false}}', () => { + const worker = ++creates; + const failure = failures[worker - 1]; + return { + fetch: (request: Request) => { + if (failure !== undefined) return Promise.reject(failure); + return request + .text() + .then((body) => new Response(`fn-ok worker-${worker} ${request.method} ${body}`)); }, - }, - EdgeRuntime: { - applySupabaseTag: () => undefined, - userWorkers: { - create: () => { - const worker = ++creates; - const failure = failures[worker - 1]; - return Promise.resolve({ - fetch: (request: Request) => { - if (failure !== undefined) return Promise.reject(failure); - return request - .text() - .then( - (body) => new Response(`fn-ok worker-${worker} ${request.method} ${body}`), - ); - }, - }); - }, - }, - }, - AbortController, - AbortSignal, - Headers, - Request, - Response, - URL, - console, - crypto, - CryptoKey, - Uint8Array, - ArrayBuffer, - atob, - btoa, - setTimeout, - clearTimeout, - TextEncoder, - TextDecoder, - structuredClone, - }; - const module = new SourceTextModule(bundled, { - context: createContext(sandbox), - identifier: "serve.main.retired-worker.bundle.js", + }; }); - yield* Effect.tryPromise(() => - module.link(() => { - throw new Error("Bundled service unexpectedly imported another module"); - }), - ); - yield* Effect.tryPromise(() => module.evaluate()); - if (serveOptions === undefined) - return yield* Effect.die("Bundled service did not register a server"); - return { handler: serveOptions.handler, creates: () => creates }; + return { handler, creates: () => creates }; }); it.live("serves a bodyless request with a fresh worker after WorkerAlreadyRetired", () => @@ -514,4 +540,94 @@ describe("stack-owned functions bootstrap", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + + describe("request body ownership", () => { + it.live("reads the rest of a request body the worker abandons", () => + Effect.gen(function* () { + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":false}}', () => ({ + fetch: (request: Request) => { + const reader = request.body?.getReader(); + return Promise.resolve(reader?.read()).then(() => { + // Not awaited: if the body were shared with the incoming request again, Bun + // would never settle this cancel and the test would hang instead of failing. + void reader?.cancel(); + return new Response("rejected", { status: 400 }); + }); + }, + })); + const { body, progress } = upload(); + + const response = yield* Effect.tryPromise(() => + handler(new Request("http://127.0.0.1/hello", { method: "POST", body, duplex: "half" })), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(400); + expect(yield* Effect.tryPromise(() => response.text())).toBe("rejected"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("settles an aborted request while the abandoned body read is pending", () => { + const { promise: readPending, resolve: markReadPending } = Promise.withResolvers(); + const pendingRead = Promise.withResolvers().promise; + const controller = new AbortController(); + return Effect.gen(function* () { + let pulls = 0; + const body = new ReadableStream({ + pull: (streamController) => { + pulls += 1; + if (pulls === 1) { + streamController.enqueue(new Uint8Array([1])); + return; + } + markReadPending(); + return pendingRead; + }, + }); + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":false}}', () => ({ + fetch: () => Promise.resolve(new Response("rejected", { status: 400 })), + })); + const pending = handler( + new Request("http://127.0.0.1/missing", { + method: "POST", + body, + duplex: "half", + signal: controller.signal, + }), + ); + + yield* Effect.tryPromise(() => readPending); + controller.abort(); + + const response = yield* Effect.tryPromise(() => pending); + expect(response.status).toBe(499); + }).pipe(Effect.provide(NodeServices.layer)); + }); + + it.live.each([ + ["an invalid token", "/hello", 401], + ["an unknown function", "/missing", 404], + ] as const)("reads the whole upload before rejecting %s", ([, path, status]) => + Effect.gen(function* () { + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":true}}', () => ({ + fetch: () => Promise.resolve(new Response("should not run")), + })); + const { body, progress } = upload(); + + const response = yield* Effect.tryPromise(() => + handler( + new Request(`http://127.0.0.1${path}`, { + method: "POST", + body, + duplex: "half", + headers: { Authorization: "Bearer invalid" }, + }), + ), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(status); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); }); diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index c1ab80144d..fb8fdd06cc 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -363,12 +363,31 @@ const shouldUsePackageJsonDiscovery = (config: FunctionConfig): Effect.Effect; +} +const requestBodyChunks = (body: RequestBodyReader) => + Stream.fromEffectRepeat( + foreign(() => body.read()).pipe( + Effect.flatMap((chunk) => (chunk.done ? Cause.done() : Effect.succeed(chunk.value))), + ), + ); +const drainRequestBody = (body: RequestBodyReader | undefined) => + body === undefined ? Effect.void : Stream.runDrain(requestBodyChunks(body)).pipe(Effect.ignore); + +export function prepareUserRequest(request: Request, body: RequestBodyReader | undefined): Request { const url = new URL(request.url); const forwardedHost = request.headers.get("x-forwarded-host"); if (forwardedHost) url.hostname = forwardedHost; - // Cloning tees the body, so an unread branch can stall early worker responses. - const forwarded = new Request(url.href, request); + // The runtime closes a connection whose request body is unread, which gateways report as 502, so + // the worker gets its own stream and cancelling it leaves the body for `drainRequestBody`. + const forwarded = new Request(url.href, { + method: request.method, + headers: request.headers, + body: body === undefined ? null : Stream.toReadableStream(requestBodyChunks(body)), + signal: request.signal, + duplex: "half", + }); forwarded.headers.delete("sb-api-key"); EdgeRuntime.applySupabaseTag(request, forwarded); return forwarded; @@ -378,6 +397,12 @@ Deno.serve({ handler: (request: Request) => Effect.runPromiseExit( Effect.gen(function* () { + // `worker.fetch` settles its body pipe before resolving, so the drain only reads what the + // worker abandoned. + const body = yield* Effect.acquireRelease( + Effect.sync(() => request.body?.getReader()), + (body) => Effect.interruptible(drainRequestBody(body)), + ); const { pathname } = new URL(request.url); if (pathname === "/_internal/health") return getResponse({ message: "ok" }, STATUS_CODE.OK); if (pathname === "/_internal/metric") @@ -435,7 +460,7 @@ Deno.serve({ staticPatterns: config.staticFiles, }), ); - return yield* foreign(() => worker.fetch(prepareUserRequest(request))); + return yield* foreign(() => worker.fetch(prepareUserRequest(request, body))); }); return yield* workerRequest.pipe( Effect.retry({ @@ -450,7 +475,7 @@ Deno.serve({ ), ), ); - }), + }).pipe(Effect.scoped), { signal: request.signal }, ).then((exit) => { if (Exit.isSuccess(exit)) return exit.value; From e03ea7c59335ac5979f3cf1926b8495f772ec642 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 08:20:55 +0000 Subject: [PATCH 08/71] fix(stack): stop Postgres containers with a fast shutdown (#6834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The PostgreSQL image sets no stop signal, so `docker stop` sends SIGTERM. PostgreSQL treats SIGTERM as a "smart" shutdown and waits for every client to disconnect. With stack services still connected, `docker stop` waits out Docker's grace period, the container is killed, and the next start runs crash recovery. ## Change - Create the database container with `--stop-signal SIGINT`, PostgreSQL's fast shutdown. The official postgres image uses the same default. - A container integration test asserts the signal and a clean exit. The native runtime already sent SIGINT. ## Stack Part 1 of 8 of the stack package simplification, based on develop. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown ← this PR 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../src/runtime/Container.integration.test.ts | 26 ++++++ packages/stack/src/runtime/Container.ts | 3 + .../src/services/Database.integration.test.ts | 88 ++++++++++++++++++- packages/stack/src/services/Database.ts | 2 + packages/stack/tests/docker-fixture.ts | 3 +- 5 files changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 634e17d57c..bbf5447d26 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -174,6 +174,32 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("stops a container with its configured stop signal", () => + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "r".repeat(64), + instanceId: "stop-signal", + env: {}, + args: [ + "-e", + "process.on('SIGINT', () => process.exit(3)); setInterval(() => {}, 1000); console.log('ready')", + ], + stopSignal: "SIGINT", + stopGraceSeconds: 20, + }); + const logs = yield* ready(process); + + yield* process.stop; + expect(yield* process.exitCode).toBe(3); + + yield* process.remove; + yield* Fiber.interrupt(logs); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("waits until a discarded container stops before returning", () => Effect.gen(function* () { const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 73e41ed6be..83596fc23a 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -44,6 +44,8 @@ interface ContainerSpec { readonly ports?: ReadonlyArray; /** Seconds `docker stop` waits before SIGKILL. Omitted means 10. */ readonly stopGraceSeconds?: number; + /** Signal `docker stop` sends first; omitted uses the image's stop signal. */ + readonly stopSignal?: "SIGINT" | "SIGTERM"; } export interface ContainerProcess { @@ -319,6 +321,7 @@ export const makeContainerRuntime = (options: { ].join(","), ]), ...(spec.workingDir === undefined ? [] : ["--workdir", spec.workingDir]), + ...(spec.stopSignal === undefined ? [] : ["--stop-signal", spec.stopSignal]), ...(spec.ports ?? []).flatMap((port) => ["--publish", `127.0.0.1::${port}`]), ...(spec.entrypoint === undefined ? [] : ["--entrypoint", spec.entrypoint]), image, diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index 21010b3712..33731f14df 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -17,7 +17,7 @@ import { tmpdir } from "node:os"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; import { makeService } from "../Service.ts"; import { makeDatabase, type BackendEndpoint, type DatabaseConfig } from "./Database.ts"; -import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { makeDockerDatabaseRoot, runDocker } from "../../tests/docker-fixture.ts"; const config: DatabaseConfig = { version: "17", @@ -459,4 +459,90 @@ describe("database component", { timeout: 180_000 }, () => { }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + + it.live("shuts PostgreSQL down fast while a client stays connected across stop", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const stackId = "stack-fast-shutdown"; + const root = yield* makeDockerDatabaseRoot("stack-database-shutdown-", stackId); + const database = yield* makeDatabase({ + stackId, + instanceId: "database", + root, + cacheRoot: artifactCacheRoot, + runtime: "docker", + }); + const service = yield* makeService(database.definition, { + id: "database:shutdown", + config: { ...config, stopGraceSeconds: 30 }, + }); + yield* service.start; + yield* service.ready; + const endpoint = yield* database.endpoint; + if (endpoint.kind !== "tcp") return yield* Effect.die("Expected a TCP endpoint"); + const listed = yield* runDocker([ + "ps", + "--filter", + `label=com.supabase.stack-root=${path.resolve(root)}`, + "--filter", + "label=com.supabase.instance=database", + "--format", + "{{.Names}}", + ]); + const containers = listed.output + .split("\n") + .filter((name) => /^supabase-[0-9a-f]{8}-[0-9a-f-]+$/u.test(name)); + expect(containers).toHaveLength(1); + const container = containers.join(""); + const startedAt = yield* runDocker([ + "inspect", + "--format", + "{{.State.StartedAt}}", + container, + ]); + + yield* Effect.scoped( + Effect.gen(function* () { + const services = yield* Layer.build( + PgClient.layer({ + host: endpoint.host, + port: endpoint.port, + database: "postgres", + username: "supabase_admin", + password: config.databasePassword, + }), + ); + const connection = yield* Context.get(services, PgClient.PgClient).reserve; + expect(yield* connection.executeUnprepared("SELECT 1 AS ok", [], undefined)).toEqual([ + { ok: 1 }, + ]); + yield* service.stop; + }), + ); + + const stoppedAt = yield* runDocker(["info", "--format", "{{.SystemTime}}"]); + const events = yield* runDocker([ + "events", + "--since", + startedAt.output.trim(), + "--until", + stoppedAt.output.trim(), + "--filter", + `container=${container}`, + "--filter", + "event=kill", + "--filter", + "event=die", + "--format", + '{{.Action}} {{index .Actor.Attributes "signal"}}{{index .Actor.Attributes "exitCode"}}', + ]); + expect( + events.output.trim().split("\n"), + "stop sends SIGINT and PostgreSQL exits cleanly", + ).toEqual(["kill 2", "die 0"]); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); }); diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 14c6e136d4..c19dba1913 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -812,6 +812,8 @@ export const makeDatabase = ( }, ], ports: [5432], + // SIGTERM is PostgreSQL's smart shutdown, which waits for every client to disconnect. + stopSignal: "SIGINT", ...(config.stopGraceSeconds === undefined ? {} : { stopGraceSeconds: config.stopGraceSeconds }), diff --git a/packages/stack/tests/docker-fixture.ts b/packages/stack/tests/docker-fixture.ts index 94a6040bd5..c315612a5d 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -2,7 +2,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { Crypto, Effect, FileSystem, Path, Stream } from "effect"; import { cleanupDockerRoot } from "./docker-cleanup.ts"; -const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray) => +/** Runs a Docker CLI command and returns its combined output and exit code. */ +export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray) => Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; From 257bee9bce93c096a6e7f9ba00fded141fd751ac Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 08:43:16 +0000 Subject: [PATCH 09/71] perf(cli): initialize catalog schemas without temporary servers (#6831) Fresh database setup runs Auth, Storage, and Realtime initialization commands concurrently, waits for successful completion, and then applies the database overlay and project SQL. It no longer creates temporary service instances or launches temporary servers for catalog setup. The stack exposes finite work through `stack.commands.run`, including PostgreSQL clients and typed initialization commands. The host owns streamed output, cancellation, failure diagnostics, and cleanup. Initialization reuses the service recipes and saved stack credentials; services retain their long-running lifecycle. Pin Realtime `v2.134.5` to the republished image containing the one-shot preparation script from https://github.com/supabase/slim-services/pull/324. Pin Functions to Edge Runtime `v1.77.1`, which fixes the SQLite cache-initialization race that caused cold-start SIGBUS crashes ([upstream fix](https://github.com/supabase/edge-runtime/pull/747)). Remove the Functions test transport retry so dropped connections fail directly. --- .../pg-dump.native.integration.test.ts | 5 +- .../pg-dump.run.integration.test.ts | 2 +- apps/cli/src/command-internal/pg-dump.run.ts | 5 +- .../stack-catalog-setup.integration.test.ts | 369 ++++++++++++------ .../command-internal/stack-catalog-setup.ts | 92 ++--- .../src/command-internal/test-db.handler.ts | 5 +- .../test-db.native.integration.test.ts | 49 ++- .../commands/db/dump/dump.integration.test.ts | 38 +- .../db/reset/reset.integration.test.ts | 2 +- .../generate/generate.integration.test.ts | 2 +- .../declarative/sync/sync.integration.test.ts | 2 +- .../db/start/start.integration.test.ts | 2 +- .../stack/prepare/prepare.integration.test.ts | 2 +- .../stack/start/start.integration.test.ts | 2 +- .../stack/status/status.integration.test.ts | 2 +- .../serve/serve.stack.integration.test.ts | 2 +- apps/cli/tests/helpers/storage.ts | 2 +- packages/stack/ARCHITECTURE.md | 82 ++-- packages/stack/README.md | 18 +- packages/stack/package.json | 2 +- packages/stack/src/Artifacts.ts | 2 +- ...n.test.ts => Commands.integration.test.ts} | 161 ++++---- packages/stack/src/Commands.ts | 116 ++++++ packages/stack/src/Owner.integration.test.ts | 37 ++ packages/stack/src/Owner.ts | 14 + packages/stack/src/Rpc.ts | 24 +- packages/stack/src/Service.ts | 2 + ....container-sweep-retry.integration.test.ts | 9 +- .../stack/src/StackHost.integration.test.ts | 115 ++++-- packages/stack/src/StackHost.ts | 47 ++- packages/stack/src/Tools.ts | 33 -- packages/stack/src/effect.integration.test.ts | 23 +- packages/stack/src/effect.ts | 200 ++++++---- ...=> CommandAttachments.integration.test.ts} | 19 +- ...olAttachments.ts => CommandAttachments.ts} | 102 ++--- ...dRunner.initialization.integration.test.ts | 234 +++++++++++ ...Runner.native-cleanup.integration.test.ts} | 21 +- .../host/{ToolRunner.ts => CommandRunner.ts} | 151 ++++--- packages/stack/src/index.ts | 96 +++-- packages/stack/src/public.e2e.test.ts | 19 +- packages/stack/src/runtime/CommandOutput.ts | 79 ++++ .../src/runtime/Container.integration.test.ts | 2 +- packages/stack/src/runtime/Container.ts | 4 +- packages/stack/src/services/Analytics.ts | 2 +- packages/stack/src/services/Auth.ts | 12 +- packages/stack/src/services/Catalog.ts | 30 +- packages/stack/src/services/Database.ts | 2 +- .../services/Functions.integration.test.ts | 26 +- packages/stack/src/services/Functions.ts | 2 +- packages/stack/src/services/Imgproxy.ts | 2 +- packages/stack/src/services/Initialization.ts | 116 ++++++ packages/stack/src/services/Mail.ts | 2 +- packages/stack/src/services/Pgmeta.ts | 2 +- packages/stack/src/services/Pooler.ts | 2 +- .../ProcessRecipe.integration.test.ts | 11 +- packages/stack/src/services/ProcessRecipe.ts | 178 +++++---- packages/stack/src/services/Realtime.ts | 9 +- packages/stack/src/services/Rest.ts | 2 +- packages/stack/src/services/Storage.ts | 9 +- packages/stack/src/services/Studio.ts | 2 +- packages/stack/src/services/Vector.ts | 2 +- .../DockerDatabaseStorage.integration.test.ts | 2 +- packages/stack/tests/whole-stack/fixture.ts | 4 +- 63 files changed, 1812 insertions(+), 800 deletions(-) rename packages/stack/src/{Tools.integration.test.ts => Commands.integration.test.ts} (76%) create mode 100644 packages/stack/src/Commands.ts delete mode 100644 packages/stack/src/Tools.ts rename packages/stack/src/host/{ToolAttachments.integration.test.ts => CommandAttachments.integration.test.ts} (79%) rename packages/stack/src/host/{ToolAttachments.ts => CommandAttachments.ts} (57%) create mode 100644 packages/stack/src/host/CommandRunner.initialization.integration.test.ts rename packages/stack/src/host/{ToolRunner.native-cleanup.integration.test.ts => CommandRunner.native-cleanup.integration.test.ts} (84%) rename packages/stack/src/host/{ToolRunner.ts => CommandRunner.ts} (59%) create mode 100644 packages/stack/src/runtime/CommandOutput.ts create mode 100644 packages/stack/src/services/Initialization.ts diff --git a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts index 8a3f1b4bee..cc68deebae 100644 --- a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts @@ -2,7 +2,8 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { FetchHttpClient } from "effect/unstable/http"; import { Effect, FileSystem, Layer, Option, Redacted, Ref, Stream } from "effect"; -import { postgres, type ServiceCreation } from "@supabase/stack/effect"; +import { type ServiceCreation } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { streamPgDumpWithClient } from "./pg-dump.run.ts"; @@ -58,7 +59,7 @@ describe("managed pg_dump against a live stack", { timeout: 180_000 }, () => { PGPASSWORD: decodeURIComponent(connection.password), PGDATABASE: connection.pathname.slice(1), }; - const setupResult = yield* stack.tools.run(postgres.psql({ major: 17 }), { + const setupResult = yield* stack.commands.run(postgres.psql({ major: 17 }), { args: ["-X", "-v", "ON_ERROR_STOP=1"], env, stdin: Stream.make( diff --git a/apps/cli/src/command-internal/pg-dump.run.integration.test.ts b/apps/cli/src/command-internal/pg-dump.run.integration.test.ts index aef3605340..a2a35a8118 100644 --- a/apps/cli/src/command-internal/pg-dump.run.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.run.integration.test.ts @@ -12,7 +12,7 @@ import { mockOutput } from "../../tests/helpers/mocks.ts"; const stackFixture = (output: string) => { const calls: Array<{ readonly args: ReadonlyArray; readonly command: string }> = []; const stack = { - tools: { + commands: { run: ( _tool: { readonly command: string }, options: { diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index ddddeee07f..d79aa107d2 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -1,5 +1,6 @@ import { Effect, Option } from "effect"; -import { postgres, type Stack } from "@supabase/stack/effect"; +import type { Stack } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; type StackRuntimePreference = | { readonly kind: "native" } | { readonly kind: "container"; readonly engine: "docker" | "podman" }; @@ -294,7 +295,7 @@ export const streamPgDumpWithClient = Effect.fn("streamPgDumpWithClient")(functi const emit = stackDumpStdout(params.script, params.env, params.onStdout); if (params.script.includes("--data-only")) yield* params.onStdout(new TextEncoder().encode("SET session_replication_role = replica;\n")); - const result = yield* params.client.stack.tools.run( + const result = yield* params.client.stack.commands.run( params.client.command === "pg_dump" ? postgres.pgDump({ major: params.client.major }) : postgres.pgDumpAll({ major: params.client.major }), diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 9dc8afcce1..08e48e349d 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -1,10 +1,24 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Option, + Redacted, + Ref, + Result, + Stream, +} from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; -import { create, postgres } from "@supabase/stack/effect"; +import { create, StackError, type Stack } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; import { mockOutput } from "../../tests/helpers/mocks.ts"; +import type { Command } from "@supabase/stack/commands"; import { stackCatalogSetupLayer, StackCatalogSetup } from "./stack-catalog-setup.ts"; import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; @@ -13,132 +27,245 @@ const jwtSecret = "stack-catalog-setup-integration-secret"; describe("stack catalog setup", { timeout: 180_000 }, () => { for (const runtime of ["native", "docker"] as const) { - it.live(`initializes selected database services on a stopped ${runtime} composition`, () => { - const buildOutput = mockOutput(); - const callOutput = mockOutput(); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-catalog-${runtime}-` }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString( - `${root}/supabase/roles.sql`, - "CREATE TABLE IF NOT EXISTS public.catalog_overlay(owner uuid REFERENCES auth.users(id), value text NOT NULL);\n", - ); - const stack = yield* create({ - projectRoot: root, - stateRoot: `${root}/state`, - cacheRoot, - runtime, - }); - yield* Effect.acquireUseRelease( - Effect.succeed(stack), - (stack) => - Effect.gen(function* () { - const members = yield* stack.composition.supabase([ - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(jwtSecret), - jwtExpiry: 3600, + it.live( + `initializes service schemas without temporary services on a stopped ${runtime} composition`, + () => { + const buildOutput = mockOutput(); + const callOutput = mockOutput(); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-catalog-${runtime}-` }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/roles.sql`, + "CREATE TABLE IF NOT EXISTS public.catalog_overlay(owner uuid REFERENCES auth.users(id), session_id uuid REFERENCES auth.sessions(id), upload_id text REFERENCES storage.s3_multipart_uploads(id), value text NOT NULL);\n", + ); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot, + runtime, + }); + yield* Effect.acquireUseRelease( + Effect.succeed(stack), + (stack) => + Effect.gen(function* () { + const members = yield* stack.composition.supabase([ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, }, - endpoints: { sql: { port: "auto" } }, - }, - { - service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "storage", - config: { - databaseUrl: "postgresql://placeholder", - jwtSecret, - filePath: `${root}/unused-storage`, + { + service: "auth", + config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + endpoints: { http: { port: "auto" } }, }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "realtime", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, - endpoints: { http: { port: "auto" }, rpc: { port: "auto" } }, - }, - ]); - const database = members.find((member) => member.service === "database"); - if (database === undefined) return yield* Effect.die("database member missing"); - yield* database.start; - yield* database.ready; - const runtimeCredentials = yield* database.credentials({ from: "runtime" }); - const databaseUrl = runtimeCredentials.databaseUrl; - if (databaseUrl === undefined) return yield* Effect.die("database URL missing"); - - const setup = yield* Effect.service(StackCatalogSetup); - yield* setup - .apply({ - target: { - stack, - database, - databaseServices: ["auth", "storage", "realtime"], + { + service: "storage", + config: { + databaseUrl: "postgresql://placeholder", + jwtSecret, + filePath: `${root}/unused-storage`, + }, + endpoints: { http: { port: "auto" } }, }, - overlay: { - webhooks: "disabled", - webhooksEnabled: false, - apiAutoExposeNewTables: Option.none(), - vault: [], - workdir: root, - announceRoles: true, + { + service: "realtime", + config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + endpoints: { http: { port: "auto" }, rpc: { port: "auto" } }, }, - }) - .pipe(Effect.provide(callOutput.layer)); - expect(callOutput.stderrText).toContain("Seeding globals from roles.sql..."); - const realtime = members.find((member) => member.service === "realtime"); - if (realtime === undefined) return yield* Effect.die("realtime member missing"); - yield* realtime.start; - yield* realtime.ready; - yield* realtime.stop; + ]); + const database = members.find((member) => member.service === "database"); + if (database === undefined) return yield* Effect.die("database member missing"); + yield* database.start; + yield* database.ready; + const runtimeCredentials = yield* database.credentials({ from: "runtime" }); + const databaseUrl = runtimeCredentials.databaseUrl; + if (databaseUrl === undefined) return yield* Effect.die("database URL missing"); + + const setup = yield* Effect.service(StackCatalogSetup); + yield* setup + .apply({ + target: { + stack, + database, + databaseServices: ["auth", "storage", "realtime"], + }, + overlay: { + webhooks: "disabled", + webhooksEnabled: false, + apiAutoExposeNewTables: Option.none(), + vault: [], + workdir: root, + announceRoles: true, + }, + }) + .pipe(Effect.provide(callOutput.layer)); + expect(callOutput.stderrText).toContain("Seeding globals from roles.sql..."); + const rows: Array = []; + const errors: Array = []; + const query = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "-At"], + stdin: Stream.make( + new TextEncoder().encode( + "select coalesce(to_regclass('auth.users')::text,'missing'), coalesce(to_regclass('auth.sessions')::text,'missing'), coalesce(to_regclass('storage.objects')::text,'missing'), coalesce(to_regclass('storage.s3_multipart_uploads')::text,'missing'), coalesce(to_regclass('realtime.messages')::text,'missing'), coalesce(to_regclass('realtime.subscription')::text,'missing'), coalesce(to_regclass('public.catalog_overlay')::text,'missing');", + ), + ), + stdout: (bytes) => + Effect.sync(() => rows.push(new TextDecoder().decode(bytes))), + stderr: (bytes) => + Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), + }); + expect(query.exitCode, errors.join("")).toBe(0); + expect(rows.join("").trim()).toBe( + "users|sessions|storage.objects|storage.s3_multipart_uploads|realtime.messages|realtime.subscription|catalog_overlay", + ); - const rows: Array = []; - const errors: Array = []; - const query = yield* stack.tools.run(postgres.psql({ major: 17 }), { - args: ["--dbname", databaseUrl, "-At"], - stdin: Stream.make( - new TextEncoder().encode( - "select coalesce(to_regclass('auth.users')::text,'missing'), coalesce(to_regclass('storage.objects')::text,'missing'), coalesce(to_regclass('realtime.messages')::text,'missing'), coalesce(to_regclass('realtime.subscription')::text,'missing'), coalesce(to_regclass('public.catalog_overlay')::text,'missing');", + const credentials = yield* stack.credentials.get; + if (credentials === undefined) + return yield* Effect.die("stack credentials missing"); + const tenantJwks: Array = []; + const tenantJwksQuery = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "-At"], + stdin: Stream.make( + new TextEncoder().encode( + "SELECT jwt_jwks::text FROM _realtime.tenants WHERE external_id = 'realtime-dev';", + ), ), - ), - stdout: (bytes) => Effect.sync(() => rows.push(new TextDecoder().decode(bytes))), - stderr: (bytes) => - Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), - }); - expect(query.exitCode, errors.join("")).toBe(0); - expect(rows.join("").trim()).toBe( - "users|storage.objects|realtime.messages|realtime.subscription|catalog_overlay", - ); + stdout: (bytes) => + Effect.sync(() => tenantJwks.push(new TextDecoder().decode(bytes))), + stderr: (bytes) => + Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), + }); + expect(tenantJwksQuery.exitCode, errors.join("")).toBe(0); + expect(JSON.parse(tenantJwks.join("").trim())).toEqual( + JSON.parse(credentials.jwks), + ); + + const realtime = members.find((member) => member.service === "realtime"); + if (realtime === undefined) return yield* Effect.die("realtime member missing"); + yield* realtime.start; + yield* realtime.ready; + yield* realtime.stop; + + const listed = yield* stack.services.list; + expect(listed.map((instance) => instance.id).sort()).toEqual( + members.map((instance) => instance.id).sort(), + ); + for (const instance of listed) { + if (instance.service !== "database") + expect((yield* instance.status).lifecycle).toBe("stopped"); + } + + const runControlledSetup = Effect.fn("StackCatalogSetup.integration.controlled")( + function* (mode: "failure" | "interruption") { + const storageReady = yield* Deferred.make(); + const authReady = yield* Deferred.make(); + const releaseStorage = yield* Deferred.make(); + const releaseAuth = yield* Deferred.make(); + const temporaryDirectory = yield* Ref.make(undefined); + const controlledStack: Stack = { + ...stack, + commands: { + ...stack.commands, + run: (invocation: Command) => { + if (!("type" in invocation)) + return Effect.die("postgres command is not used in this fixture"); + if (invocation.type === "storage.initialize") + return Ref.set(temporaryDirectory, invocation.filePath).pipe( + Effect.andThen(Deferred.succeed(storageReady, undefined)), + Effect.andThen(Deferred.await(releaseStorage)), + Effect.as({ jobId: "controlled-storage", exitCode: 0 as const }), + ); + if (mode === "interruption") + return Deferred.succeed(authReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseAuth)), + Effect.as({ jobId: "controlled-auth", exitCode: 0 as const }), + ); + return Deferred.await(storageReady).pipe( + Effect.andThen( + Effect.fail( + new StackError({ + operation: "initialize", + message: "controlled Auth initialization failure", + }), + ), + ), + ); + }, + }, + }; + const beforeOutput = callOutput.stderrText; + const run = setup + .apply({ + target: { + stack: controlledStack, + database, + databaseServices: ["auth", "storage"], + }, + overlay: { + webhooks: "disabled", + webhooksEnabled: false, + apiAutoExposeNewTables: Option.none(), + vault: [], + workdir: root, + announceRoles: true, + }, + }) + .pipe(Effect.provide(callOutput.layer)); + + if (mode === "failure") { + const result = yield* Effect.result(run); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) + expect(result.failure.message).toContain( + "controlled Auth initialization failure", + ); + } else { + const fiber = yield* Effect.forkScoped(run); + yield* Deferred.await(storageReady); + yield* Deferred.await(authReady); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + expect(Exit.hasInterrupts(exit)).toBe(true); + } + + expect(callOutput.stderrText).toBe(beforeOutput); + const temporaryPath = yield* Ref.get(temporaryDirectory); + if (temporaryPath === undefined) + return yield* Effect.die("temporary storage directory was not created"); + expect(yield* fs.exists(temporaryPath)).toBe(false); + const current = yield* stack.services.list; + expect(current.map((instance) => instance.id).sort()).toEqual( + members.map((instance) => instance.id).sort(), + ); + }, + ); - const listed = yield* stack.services.list; - expect(listed.map((instance) => instance.id).sort()).toEqual( - members.map((instance) => instance.id).sort(), - ); - for (const instance of listed) { - if (instance.service !== "database") - expect((yield* instance.status).lifecycle).toBe("stopped"); - } - }), - destroyTestStack, - ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - FetchHttpClient.layer, - buildOutput.layer, - stackCatalogSetupLayer, + yield* runControlledSetup("failure"); + yield* runControlledSetup("interruption"); + }), + destroyTestStack, + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + FetchHttpClient.layer, + buildOutput.layer, + stackCatalogSetupLayer, + ), ), - ), - ); - }); + ); + }, + ); } }); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.ts b/apps/cli/src/command-internal/stack-catalog-setup.ts index 01c152d8bf..585c27199f 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.ts @@ -1,10 +1,6 @@ import { Context, Data, Effect, FileSystem, Layer, Path } from "effect"; -import { - type DatabaseInstance, - type ServiceCreationInput, - type ServiceInstance, - type Stack, -} from "@supabase/stack/effect"; +import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { initialization, type InitializationCommand } from "@supabase/stack/commands"; import { Output } from "../shared/output/output.service.ts"; import { actionability, @@ -32,11 +28,6 @@ interface ServiceCredentials { readonly storageDatabaseUrl: string; } -type TemporaryServiceInstance = - | ServiceInstance<"auth"> - | ServiceInstance<"storage"> - | ServiceInstance<"realtime">; - export class StackCatalogSetupError extends Data.TaggedError("StackCatalogSetupError")<{ readonly message: string; readonly cause?: unknown; @@ -78,16 +69,6 @@ type StackCatalogSetupFailure = | MigrationVaultError | DbConnectError; -const temporaryServiceError = ( - operation: "start" | "destroy", - instance: TemporaryServiceInstance, - cause: unknown, -): StackCatalogSetupError => - new StackCatalogSetupError({ - message: `temporary ${instance.service} service ${instance.id} failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - }); - const credential = ( credentials: Readonly>, name: string, @@ -98,55 +79,28 @@ const credential = ( : Effect.succeed(value); }; -const serviceDefinition = ( +const serviceCommand = ( service: DatabaseService, credentials: ServiceCredentials, storagePath: string, -): Extract => { +): InitializationCommand => { switch (service) { case "auth": - return { - service, - config: { - databaseUrl: credentials.authDatabaseUrl, - }, - endpoints: {}, - }; + return initialization.auth({ + databaseUrl: credentials.authDatabaseUrl, + }); case "storage": - return { - service, - config: { - databaseUrl: credentials.storageDatabaseUrl, - filePath: storagePath, - }, - endpoints: {}, - }; + return initialization.storage({ + databaseUrl: credentials.storageDatabaseUrl, + filePath: storagePath, + }); case "realtime": - return { - service, - config: { - databaseUrl: credentials.databaseUrl, - }, - endpoints: {}, - }; + return initialization.realtime({ + databaseUrl: credentials.databaseUrl, + }); } }; -const startTemporaryService = ( - instance: TemporaryServiceInstance, -): Effect.Effect => - instance.start.pipe( - Effect.andThen(instance.ready), - Effect.mapError((cause) => temporaryServiceError("start", instance, cause)), - ); - -const destroyTemporaryService = ( - instance: TemporaryServiceInstance, -): Effect.Effect => - instance.destroy.pipe( - Effect.mapError((cause) => temporaryServiceError("destroy", instance, cause)), - ); - const applyCatalog = Effect.fn("StackCatalogSetup.apply")(function* ( input: StackCatalogSetupInput, ) { @@ -177,14 +131,16 @@ const applyCatalog = Effect.fn("StackCatalogSetup.apply")(function* ( yield* Effect.forEach( input.target.databaseServices, (service) => - Effect.acquireUseRelease( - input.target.stack.services - .create(serviceDefinition(service, serviceCredentials, storagePath)) - .pipe(Effect.mapError(catalogError)), - startTemporaryService, - destroyTemporaryService, - ), - { discard: true }, + input.target.stack.commands + .run(serviceCommand(service, serviceCredentials, storagePath)) + .pipe( + Effect.asVoid, + Effect.mapError(catalogError), + Effect.withSpan("StackCatalogSetup.initializeCatalogService", { + attributes: { service, operation: "initialize" }, + }), + ), + { concurrency: "unbounded", discard: true }, ); const connection = parseConnectionString(hostDatabaseUrl); diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 7769dc7ce1..f5010ec342 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -1,6 +1,7 @@ import * as nodePath from "node:path"; import { Effect, FileSystem, Option, Path } from "effect"; -import { postgres, type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; @@ -296,7 +297,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { : nodePath.extname(hostPath) !== "" ? nodePath.dirname(hostPath) : hostPath; - return yield* managedStack.stack.tools + return yield* managedStack.stack.commands .run(postgres.pgProve({ major: managedStack.major }), { args: args.cmd.slice(1), env: runEnv, diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index 4049e4377f..e638e3bf94 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -23,6 +23,9 @@ import { testDb } from "./test-db.handler.ts"; import { runTestDbCommand } from "./test-db.command-handler.ts"; import { DockerRun } from "./docker-run.service.ts"; import { StackError } from "@supabase/stack/effect"; +import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; +import type { Stack } from "@supabase/stack/effect"; +import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; @@ -158,20 +161,48 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { const partialTap = "not ok 1 - managed tool failed after streaming\n"; const jsonOutput = mockOutput({ format: "json" }); const processControl = mockProcessControl(); + const failingCommands = (commands: Stack["commands"]) => { + function run( + command: PostgresCommand, + options: PostgresCommandOptions, + ): Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + E | StackError, + R + >; + function run( + command: InitializationCommand, + options?: InitializationCommandOptions, + ): Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + E | StackError, + R + >; + function run( + command: PostgresCommand | InitializationCommand, + options?: PostgresCommandOptions | InitializationCommandOptions, + ) { + if ("type" in command) return commands.run(command); + if (options?.stdout === undefined) + return Effect.die("Postgres command requires a stdout sink"); + const stdout = options.stdout; + return Effect.gen(function* () { + yield* stdout(new TextEncoder().encode(partialTap)); + return yield* Effect.fail( + new StackError({ operation: "command", message: "pg_prove unavailable" }), + ); + }); + } + return run; + }; const failingStackApi = Layer.succeed(StackApi, { ...api, open: () => Effect.succeed({ ...stack, - tools: { - ...stack.tools, - run: (_tool, options) => - Effect.gen(function* () { - yield* options.stdout(new TextEncoder().encode(partialTap)); - return yield* Effect.fail( - new StackError({ operation: "tool", message: "pg_prove unavailable" }), - ); - }), + commands: { + ...stack.commands, + run: failingCommands(stack.commands), }, }), }); diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 4ffc61ae51..d46bbea1e3 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -33,7 +33,9 @@ import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; import type { RunPostgresClientOptions } from "../../../command-internal/bundled-postgres-client.ts"; -import type { DatabaseInstance, ServiceCreation, Stack } from "@supabase/stack/effect"; +import type { DatabaseInstance, ServiceCreation, Stack, StackError } from "@supabase/stack/effect"; +import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; +import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; import type { DbDumpFlags } from "./dump.command.ts"; import { dbDump } from "./dump.handler.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; @@ -66,6 +68,26 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { }, endpoints: { sql: { port: 54322 } }, }; + function runCommand( + command: PostgresCommand, + options: PostgresCommandOptions, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function runCommand( + command: InitializationCommand, + options?: InitializationCommandOptions, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function runCommand( + command: PostgresCommand | InitializationCommand, + options?: PostgresCommandOptions | InitializationCommandOptions, + ) { + if ("type" in command) return Effect.die("initializer is unused"); + if (options?.stdout === undefined) return Effect.die("Postgres command requires a stdout sink"); + const stdout = options.stdout; + return Effect.gen(function* () { + yield* stdout(new TextEncoder().encode('CREATE TABLE "public" (id integer);\n')); + return { jobId: "job", exitCode: 0 }; + }); + } const database: DatabaseInstance = { id, service: "database", @@ -119,19 +141,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { }, stop: Effect.void, destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), - tools: { - run: ( - _tool: { readonly command: string; readonly major: number }, - options: { - readonly stdout: (bytes: Uint8Array) => Effect.Effect; - readonly stderr: (bytes: Uint8Array) => Effect.Effect; - }, - ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E, R> => - Effect.gen(function* () { - yield* options.stdout(new TextEncoder().encode('CREATE TABLE "public" (id integer);\n')); - return { jobId: "job", exitCode: 0 }; - }), - }, + commands: { run: runCommand }, } satisfies Stack; return Layer.succeed(StackApi, { create: () => Effect.succeed(stack), diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 6966113319..56feb36ca9 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -789,7 +789,7 @@ function mockResetStackApi(opts: { }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, }; return { layer: Layer.succeed(StackApi, { diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index d1dc4bab04..11c9cbde51 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -163,7 +163,7 @@ function generateStackApi(workdir: string) { }, stop: unusedStack, destroy: unusedStack, - tools: { run: unusedStackFn }, + commands: { run: unusedStackFn }, }; const identity = { projectRoot: workdir, branchContext: "main", stackName: "default" }; return Layer.succeed(StackApi, { diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index c276ec3fe2..f9f6b352ab 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -165,7 +165,7 @@ function syncStackApi(workdir: string, port: number) { }, stop: unusedSync, destroy: unusedSync, - tools: { run: unusedSyncFn }, + commands: { run: unusedSyncFn }, }; const identity = { projectRoot: workdir, branchContext: "main", stackName: "default" }; return Layer.succeed(StackApi, { diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index dfc2d09c2b..04000a56b1 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1706,7 +1706,7 @@ describe("db start stack backend", () => { }, stop: Effect.void, destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, }; return { stack, state }; }; diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index 09a5263eba..9185ce22f4 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -131,7 +131,7 @@ const makeFixture = (root: string, failPreparation = false) => { }, stop: Effect.void, destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, }; const output = mockOutput(); const telemetry = mockTelemetryStateTracked(); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 1b414bde07..027d19ad08 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -289,7 +289,7 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { hostDestroyed += 1; return { runtimeCleanup: "complete" as const }; }), - tools: { run: () => Effect.die("tool not used") }, + commands: { run: () => Effect.die("command not used") }, }; return { stack, diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 3de8717b86..6a31527d8d 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -137,7 +137,7 @@ const makeStack = ( }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { + commands: { run: (_tool, _options) => Effect.die("unused"), }, }); diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index b4712285ee..68a5e770c1 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -284,7 +284,7 @@ const fixture = ( }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, } satisfies Stack; const identity = { projectRoot: "/project", branchContext: "main", stackName: "default" }; const apiService = { diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 942bef7e27..e9088cf51c 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -263,7 +263,7 @@ export function buildStorageStackApi( }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, }; const definition = { id: STORAGE_STACK_ID, diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 380d2a1cef..0f6fc97f4f 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -1,10 +1,10 @@ # Stack package architecture -This document defines the stack package architecture. The package implements the service graph, lifecycle, composition, proxy, tools, and detached owner described here. CLI integration is a separate consumer concern. +This document defines the stack package architecture. The package implements the service graph, lifecycle, composition, proxy, commands, and detached owner described here. CLI integration is a separate consumer concern. ## Core model -Use **a graph of service instances, with a small serialized lifecycle for each instance**. Keep application readiness separate from that lifecycle. Add a proxy that starts services on public traffic and sleeps them on public inactivity. Run finite tools with the same identity, artifacts and execution primitives, without treating them as services. +Use **a graph of service instances, with a small serialized lifecycle for each instance**. Keep application readiness separate from that lifecycle. Add a proxy that starts services on public traffic and sleeps them on public inactivity. Run finite commands with the same identity, artifacts and execution primitives, without treating them as services. The critical simplification is: @@ -46,22 +46,22 @@ Each long-running service is an individually identified instance with its own ex ## Implementation organization -Keep the implementation Effect V4 from the domain inward. Promise is the outer facade for package consumers and the boundary for foreign APIs; adapt a foreign Promise once at its leaf with typed errors. Host-owned execution fibers own admitted transitions, while callers may cancel only their wait. Use bounded streams and backpressure for tool and log transport. +Keep the implementation Effect V4 from the domain inward. Promise is the outer facade for package consumers and the boundary for foreign APIs; adapt a foreign Promise once at its leaf with typed errors. Host-owned execution fibers own admitted transitions, while callers may cancel only their wait. Use bounded streams and backpressure for command and log transport. Organize by cohesive responsibilities. The package shape is: - `src/` modules: the instance executor, orchestrator, owner, detached host, networking, persistence, and RPC boundary. - `services/`: one definition per service, owning its configuration, endpoints, launch settings, and readiness. `Catalog.ts` validates and dispatches creation; `Recipe.ts` defines their contract and `ProcessRecipe.ts` shares process mechanics. - `composition/Supabase.ts`: default Supabase membership, dependency edges, and input wiring. -- `host/`: endpoint projections, tool execution, and tool attachment transport. +- `host/`: endpoint projections, command execution, and command attachment transport. - `runtime/`: native and container adapters. -- `Tools.ts`: public finite-tool descriptors. +- `Commands.ts`: public finite-command descriptors. - `effect.ts`: Effect-facing composition and services. - `index.ts`: Promise-facing public boundary. This is navigational guidance, not a required file scaffold. Split modules when a responsibility needs it; avoid one folder or interface per operation. Keep service definitions narrow, with graph edges and input wiring in composition. Do not introduce capabilities, projections, recovery journals, reservations, public sleep APIs, or extra lifecycle states to force this shape. -Application services use Effect `Context.Service` and `Layer.effect`; consumers obtain their dependencies from the Effect context. Each owner receives an isolated orchestrator graph. Tools share the host lifetime alongside the owner. Individual executors, recipes, and process handles remain scoped resources because a stack owns multiple independently identified instances. +Application services use Effect `Context.Service` and `Layer.effect`; consumers obtain their dependencies from the Effect context. Each owner receives an isolated orchestrator graph. Commands share the host lifetime alongside the owner. Individual executors, recipes, and process handles remain scoped resources because a stack owns multiple independently identified instances. ## 1. Follow Compose's useful separation @@ -126,7 +126,7 @@ Dependency waits and health waits never hold a lifecycle transition open. Artifa | Composition | Explicit member selection, dependency edges, input wiring and eager/lazy activation policy | | Service recipe | Typed configuration inputs, native/container launch specifications, readiness and optional storage operations; no stack graph knowledge | | Service instance | Immutable ID, recipe/configuration, runtime handle, lifecycle and health observations; graph relationships belong to stack composition | -| Tool job | One finite artifact-backed execution belonging to the stack | +| Command job | One finite artifact-backed execution belonging to the stack | The default Supabase composition is a factory that registers, selects and connects instances. Registration establishes ownership; membership establishes participation in the default application. Registering or starting another instance never implicitly adds it to that composition. A shadow database is an ordinary database instance with its own ID, password, ports and data, owned by the same stack but managed individually. @@ -139,7 +139,7 @@ flowchart TB end ShadowA["Standalone database A"] ShadowB["Standalone database B"] - Tool["Temporary tool invocation"] + Command["Temporary command invocation"] end ``` @@ -209,9 +209,9 @@ Make operation scope explicit in the proposed API: | `instance.start/stop/restart/destroy()` | That instance, subject to the ordinary graph rules | | `stack.composition.start/stop/restart()` | Default composition members; start also includes their declared prerequisites | | `stack.stop()` | All owned service instances, including standalone instances | -| `stack.destroy()` | All owned resources, including standalone instances and tool jobs | +| `stack.destroy()` | All owned resources, including standalone instances and command jobs | -Namespace stop also cancels and settles attached jobs before StackHost shutdown; composition stop does not reserve services on behalf of tools. Namespace destruction performs shutdown before owned-data removal. The CLI's full stop uses namespace scope; restarting the application composition does not restart shadows. Composition startup never resurrects a standalone instance. Membership and eager/lazy activation are separate: eager means start when the containing composition is started, not include every registered eager instance. The existing stack-wide start convenience, if retained, delegates to default composition startup only. +Namespace stop also cancels and settles attached jobs before StackHost shutdown; composition stop does not reserve services on behalf of commands. Namespace destruction performs shutdown before owned-data removal. The CLI's full stop uses namespace scope; restarting the application composition does not restart shadows. Composition startup never resurrects a standalone instance. Membership and eager/lazy activation are separate: eager means start when the containing composition is started, not include every registered eager instance. The existing stack-wide start convenience, if retained, delegates to default composition startup only. For example, REST binds to the primary database; a shadow database has no relationship to it unless the caller asks to copy its initialization profile. Functions may run without PostgreSQL or Auth. An API URL in configuration is not automatically a hard lifecycle dependency. @@ -245,7 +245,7 @@ Cancellation has a limited meaning at each boundary: | Preparing artifacts or waiting for execution admission | Abandon this request before its lifecycle operation begins; shared preparation may continue for other callers | | Waiting for an executing instance operation | Stop waiting; the StackHost finishes the operation and its cleanup | | Waiting for readiness or following logs | End the observation without changing lifecycle | -| Running an attached tool invocation | Terminate and clean up that invocation | +| Running an attached command invocation | Terminate and clean up that invocation | A service is stopped by an explicit stop command, not by a disconnected caller. Launch completes at running, so stop during health-starting uses the ordinary stop operation. If the launch ends while a caller awaits readiness, that observation fails rather than attaching to a later launch. @@ -312,7 +312,7 @@ Composition restart is two passes: stop the selected instances in reverse depend A recipe receives ordinary typed configuration and a context for its own resources. A database URL is just an input value: the recipe does not receive its producer’s identity, another service handle, or the stack graph. It does not receive the entire persisted stack document. The orchestrator owns resolving managed outputs into these configuration values. -The backend provides mechanical operations: launch, observe exit/logs, stop and remove exact resources. Native and container implementations differ in commands, mounts, network setup and cleanup, but share the lifecycle contract. Keep one runtime choice per stack; mixed backends and automatic tool-runtime fallback are unnecessary for the current scope. Preparation validates that the selected service/tool artifact exists for that runtime and platform, and reports an unsupported-platform/artifact error otherwise. A native stack requires native artifacts; Windows native support is not implied by a fallback branch in the current CLI. +The backend provides mechanical operations: launch, observe exit/logs, stop and remove exact resources. Native and container implementations differ in commands, mounts, network setup and cleanup, but share the lifecycle contract. Keep one runtime choice per stack; mixed backends and automatic command-runtime fallback are unnecessary for the current scope. Preparation validates that the selected service/command artifact exists for that runtime and platform, and reports an unsupported-platform/artifact error otherwise. A native stack requires native artifacts; Windows native support is not implied by a fallback branch in the current CLI. A successful launch returns an owned runtime handle with readiness observation and cleanup. One shared readiness program belongs to each runtime launch and has a bounded outcome. Readiness timeout or initialization failure leaves the process running with unhealthy status and an actionable error. It does not automatically stop or restart the process. Stop remains available, and traffic/dependent launches do not treat unhealthy as ready. Initialization that requires a running process belongs to that runtime session, not the start transition. For PostgreSQL, healthy means required catalog initialization and credential reconciliation have completed—not merely that TCP accepts a connection. Stopping the running instance closes that session, settling its health probes and initialization helpers as ordinary resource cleanup. There are not two competing lifecycle operations. @@ -353,11 +353,11 @@ Sleep retains the public listener needed to wake. Whole-stack stop closes listen Composition validation permits lazy activation only for instances with a configured public wake endpoint. A route-less prerequisite, such as pg-meta without its own public endpoint, is eager; it is not implicitly armed through a dependent. This avoids a second wake-permission mechanism. Internal sleep is available only for instances with a supported public wake route and an enabled idle policy. Database and Functions need no automatic idle timer by default. Functions inspector access remains possible while health is starting; ordinary application traffic waits for healthy. -## 6. Tools share execution, not service lifecycle +## 6. Commands share execution, not service lifecycle Provide a stack-scoped finite runner for concrete CLI needs such as `pg_dump` and `psql`. Each invocation has a job ID; temp files, logs and runtime resources belong to that stack. Jobs reuse artifact selection and native/container process execution. They have exit results and byte streams, not healthchecks, wake policy or service registrations. -For `pg_dump` and `psql`, container mode launches a temporary tool container from a compatible PostgreSQL image, overriding its entrypoint to run the client command. It may reuse the database service's image, but it creates a separate container without starting another database server or mounting the server's data directory. Native mode launches the corresponding binary from the selected native artifact. The tool definition supplies these two executable forms; the runner supplies stack ownership, arguments, environment, streams and cleanup. Docker supports entrypoint overrides and automatic removal for this execution shape. [Docker run reference](https://docs.docker.com/engine/containers/run/). +For `pg_dump` and `psql`, container mode launches a temporary command container from a compatible PostgreSQL image, overriding its entrypoint to run the client command. It may reuse the database service's image, but it creates a separate container without starting another database server or mounting the server's data directory. Native mode launches the corresponding binary from the selected native artifact. The command definition supplies these two executable forms; the runner supplies stack ownership, arguments, environment, streams and cleanup. Docker supports entrypoint overrides and automatic removal for this execution shape. [Docker run reference](https://docs.docker.com/engine/containers/run/). | Invocation | Native runtime | Container runtime | | ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------ | @@ -365,28 +365,28 @@ For `pg_dump` and `psql`, container mode launches a temporary tool container fro | Connect to a managed database | Resolved public proxy endpoint | The same public proxy endpoint, addressed from the container network | | Return the result | Stream stdout/stderr and collect exit status | Stream stdout/stderr, collect exit status and remove the owned container | -The caller supplies ordinary arguments and environment values, including any connection string. The tool runner treats them as opaque: it does not resolve service references, infer dependencies or rewrite URLs. The caller can obtain execution-reachable connection values through `service.credentials({ from: "runtime" })`; `from: "host"` is the default for ordinary host clients. This extends the existing read operation rather than adding tool-specific inputs. The networking/endpoint renderer supplies these concrete values; `localhost` inside a tool container is not generally the host. Managed tool traffic uses the public proxy so the existing wake/activity rules apply. Tools publish no listening ports. Dump output streams to the CLI's destination; temporary files, when needed, use the stack/job directory. Use a client version compatible with the target; matching the managed database's selected major is the simple default. [PostgreSQL client compatibility](https://www.postgresql.org/docs/17/app-pgdump.html). +The caller supplies ordinary arguments and environment values, including any connection string. The command runner treats them as opaque: it does not resolve service references, infer dependencies or rewrite URLs. The caller can obtain execution-reachable connection values through `service.credentials({ from: "runtime" })`; `from: "host"` is the default for ordinary host clients. This extends the existing read operation rather than adding command-specific inputs. The networking/endpoint renderer supplies these concrete values; `localhost` inside a command container is not generally the host. Managed command traffic uses the public proxy so the existing wake/activity rules apply. Commands publish no listening ports. Dump output streams to the CLI's destination; temporary files, when needed, use the stack/job directory. Use a client version compatible with the target; matching the managed database's selected major is the simple default. [PostgreSQL client compatibility](https://www.postgresql.org/docs/17/app-pgdump.html). -Managed-local execution uses this temporary-container pattern under stack ownership. Executing a command inside an existing service container is unnecessary for these network clients and need not become a second public tool mechanism. +Managed-local execution uses this temporary-container pattern under stack ownership. Executing a command inside an existing service container is unnecessary for these network clients and need not become a second public command mechanism. For the managed local backend, the StackHost owns jobs so it can clean up exact resources after client disconnection. Attached-job cancellation stops that job, not a service transition. Explicit StackHost shutdown cancels and settles attached jobs; finishing the last job does not automatically retire the host. Use bounded, backpressured stdin/stdout/stderr transport; do not buffer entire dumps or confuse output EOF with successful exit. -Tools do not participate in the service dependency graph. Traffic to a public endpoint wakes a sleeping service through the proxy, exactly as traffic from any other client does. An explicitly stopped service remains stopped. There are no tool-specific readiness checks or lifecycle locks: an explicit stop or restart can disrupt the connection and the tool reports its ordinary error. Internal bootstrap helpers use the same low-level runner under their parent service operation. +Commands do not participate in the service dependency graph. Traffic to a public endpoint wakes a sleeping service through the proxy, exactly as traffic from any other client does. An explicitly stopped service remains stopped. There are no command-specific readiness checks or lifecycle locks: an explicit stop or restart can disrupt the connection and the command reports its ordinary error. Internal bootstrap helpers use the same low-level runner under their parent service operation. -Migrate **managed-local** dump/reset execution first. Linked/remote and legacy compose CLI paths remain outside this package's lifecycle; do not create a stack or reserve ports just to run their tools. An explicitly supplied stack handle may still run a job against an external URL. The identity claim applies to everything executed through that stack. +Migrate **managed-local** dump/reset execution first. Linked/remote and legacy compose CLI paths remain outside this package's lifecycle; do not create a stack or reserve ports just to run their commands. An explicitly supplied stack handle may still run a job against an external URL. The identity claim applies to everything executed through that stack. CLI policy remains CLI policy: SQL scripts, migrations, seeds, hosted targets, output files and command flags. The caller chooses a compatible client version; the runner resolves that version to its executable artifact and owns execution. -### Proposed public tool API +### Proposed public command API -Expose one awaited `stack.tools.run` operation. The Promise facade below has an Effect counterpart for CLI consumers; both use the same owner-side runner. +Expose one awaited `stack.commands.run` operation. PostgreSQL client commands and one-shot service initialization share the same owner-side runner. Initialization runs a service recipe without registering a service instance or changing the service graph. ```ts -import { postgres } from "@supabase/stack/tools"; +import { postgres } from "@supabase/stack/commands"; // Connection values are plain data, rendered for the stack runtime. const { databaseUrl } = await database.credentials({ from: "runtime" }); -const result = await stack.tools.run(postgres.pgDump({ major: 17 }), { +const result = await stack.commands.run(postgres.pgDump({ major: 17 }), { args: ["--dbname", databaseUrl, "--schema-only", "--no-owner"], stdout: (bytes) => destination.write(bytes), stderr: (bytes) => diagnostics.write(bytes), @@ -394,19 +394,23 @@ const result = await stack.tools.run(postgres.pgDump({ major: 17 }), { }); // result: { jobId, exitCode } + +await stack.commands.run({ type: "auth.initialize", databaseUrl }); +await stack.commands.run({ type: "storage.initialize", databaseUrl, filePath }); +await stack.commands.run({ type: "realtime.initialize", databaseUrl }); ``` -`postgres.pgDump({ major })` describes the selected client package and how to launch its native and container forms. The caller chooses the version; 17 is illustrative. The stack's runtime chooses the execution form. The invocation supplies ordinary arguments, environment and byte streams. Neither descriptor nor invocation declares managed service dependencies. +`postgres.pgDump({ major })` describes the selected client package and how to launch its native and container forms. The caller chooses the version; 17 is illustrative. The stack's runtime chooses the execution form. The invocation supplies ordinary arguments, environment and byte streams. Initialization invocations select Auth, Storage, or Realtime, provide the database URL, and provide Storage's file path. They require stack credentials already established by database or composition setup; credential lookup is read-only and fails when no stack identity has been established. They resolve the same service recipe artifact, environment, mounts and working directory as normal service launch while leaving no service instance behind. The CLI awaits selected initialization commands concurrently before applying its catalog overlay. -`databaseUrl` is a plain string. The same invocation can target the primary database, a shadow database or an external database without changing the tool definition or the runner. The proxy handles any wake-up caused by connecting to a sleeping managed service. A URL obtained for container execution is already reachable from that container; endpoint address selection remains outside the generic tool runner. The orchestrator uses the same endpoint renderer when supplying connection values to service instances. It handles the host gateway and the listener binding needed to reach it, not just hostname substitution, and returns an error if the selected networking configuration cannot reach the endpoint. Caller-supplied external URLs remain unchanged. +`databaseUrl` is a plain string. The same invocation can target the primary database, a shadow database or an external database without changing the command definition or the runner. The proxy handles any wake-up caused by connecting to a sleeping managed service. A URL obtained for container execution is already reachable from that container; endpoint address selection remains outside the generic command runner. The orchestrator uses the same endpoint renderer when supplying connection values to service instances. It handles the host gateway and the listener binding needed to reach it, not just hostname substitution, and returns an error if the selected networking configuration cannot reach the endpoint. Caller-supplied external URLs remain unchanged. Arguments are passed as an argument vector, not interpreted as shell text. Stdin accepts an optional asynchronous byte stream; stdout/stderr callbacks receive bytes and may return Promises, which the runner awaits for backpressure. All examples use byte sinks whose writes await capacity. `psql` uses the same operation with `postgres.psql({ major })`, optionally supplying stdin. Callers supply stdout/stderr sinks explicitly. Neither facade collects unbounded output into a return value. -The operation settles after process exit, output delivery and owned-resource cleanup. A nonzero tool exit is returned in `exitCode` for CLI-specific handling; preparation, transport, sink and cleanup failures reject with typed execution errors. Cancellation stops and cleans up the attached job and reports cancellation. The StackHost assigns `jobId` and owns the resources. No public job registry, job healthcheck, persistent tool service or separate launch/attach/wait sequence is needed for these use cases. +The operation settles after process exit, output delivery and owned-resource cleanup. PostgreSQL client commands return a nonzero process exit in `exitCode` for CLI-specific handling; initialization commands reject with a typed error on nonzero exit. Preparation, transport, sink and cleanup failures also reject with typed execution errors. Cancellation stops and cleans up the attached job and reports cancellation. The StackHost assigns `jobId` and owns the resources. No public job registry, job healthcheck, persistent command service or separate launch/attach/wait sequence is needed for these use cases. ## 7. StackHost owns lifetime; components own behavior -Use `StackHost` for the detached process that owns one stack identity. It hosts the service executors, composition orchestrator, proxy, tool runner and shared resources. A standalone instance needs this owner and its executor without needing composition scheduling. Tools use the runner without entering the service dependency graph. +Use `StackHost` for the detached process that owns one stack identity. It hosts the service executors, composition orchestrator, proxy, command runner and shared resources. A standalone instance needs this owner and its executor without needing composition scheduling. Commands use the runner without entering the service dependency graph. The host acquires exclusive ownership, constructs the components, exposes Effect RPC, delegates requests and coordinates explicit shutdown. Domain behavior remains in the components: the host does not understand PostgreSQL archives, decide dependency order or implement another service state machine. Existing dependency checks still apply when individual operations target instances with declared graph relationships. @@ -416,7 +420,7 @@ flowchart TB subgraph Host["StackHost — one detached process per stack identity"] RPC --> Composition["Composition orchestrator"] RPC --> Instances["Service instance executors"] - RPC --> Tools["Tool runner"] + RPC --> Commands["Command runner"] Composition --> Instances Proxy["Public proxy"] --> Composition Resources["Shared ownership, ports and metadata"] @@ -435,13 +439,13 @@ Keep Effect RPC as the transport initially. Its handlers should mostly delegate | Instance observation | `service.status`, `service.ready`, `service.followStatus`, `service.logs`, `service.followLogs`, `service.credentials` | Read state, await health and inspect outputs | | Database snapshots | `database.saveSnapshot`, `database.restoreSnapshot` | Database-specific managed storage operations | | Composition | `composition.configure`, `composition.describe`, `composition.start`, `composition.stop`, `composition.restart` | Define and operate the application selection and dependencies | -| Tools | `tools.run`, `tools.writeStdin`, `tools.closeStdin` | Execute an attached command with streamed input/output | +| Commands | `runCommand`, `commandInput` | Execute an attached command with streamed input/output | These are proposed wire names. Public `shadow.start()` maps to `service.start({ id })`; `stack.stop()` maps to `host.stop`. Public `stack.composition.stop()` leaves the host and independent instances available. `composition.configure` sends validated declarative membership, edges and input wiring, not executable callbacks. Configuration changes use the same graph and lifecycle admission rules as other mutations. ### Host lifecycle -Launch the host when an operation needs a live owner. Once started, it remains alive until namespace stop or destruction. Client disconnection, completion of a tool, or stopping the final individual instance does not cause automatic retirement. The accepted tradeoff is one resident process for an opened stack until explicitly stopped, even if all its service instances are stopped. +Launch the host when an operation needs a live owner. Once started, it remains alive until namespace stop or destruction. Client disconnection, completion of a command, or stopping the final individual instance does not cause automatic retirement. The accepted tradeoff is one resident process for an opened stack until explicitly stopped, even if all its service instances are stopped. ```mermaid stateDiagram-v2 @@ -460,18 +464,18 @@ During Starting, acquire the exclusive stack lease, load the instance definition During Serving, keep the owner alive independently of callers. Sleeping instances still need its public listeners. This is process lifetime management, not automatic service restart or continuous reconciliation. -Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and tools, then synchronously removes containers labeled with the stack and canonical data root before the host exits. Forced termination may require manual cleanup. +Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and commands, then synchronously removes containers labeled with the stack and canonical data root before the host exits. Forced termination may require manual cleanup. During Draining: 1. Close admission to new mutations and proxy wake requests. 2. Reject queued work that has not begun. 3. Let executing instance operations settle. -4. Cancel and settle attached tools and stop owned services through their existing operations. +4. Cancel and settle attached commands and stop owned services through their existing operations. 5. For destruction, remove proven-owned data and metadata after shutdown. 6. Send the outcome, close the control endpoint and release ownership. -**Successful whole-stack shutdown.** `stack.stop()` reports success only after admitted work settles, every owned live service and tool workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close, the shutdown RPC is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the live host retains ownership for inspection and retry; the stack is not reported stopped. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and saved port assignments. `destroy` follows the same live-workload cleanup, then removes only proven-owned persistent data. +**Successful whole-stack shutdown.** `stack.stop()` reports success only after admitted work settles, every owned live service and command workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close, the shutdown RPC is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the live host retains ownership for inspection and retry; the stack is not reported stopped. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and saved port assignments. `destroy` follows the same live-workload cleanup, then removes only proven-owned persistent data. The client captures the live owner PID from the validated identity endpoint or readiness handshake, completes and closes the shutdown RPC, then performs bounded process-existence checks. An absent PID confirms exit; a permission-denied probe remains inconclusive until the deadline. Failure to confirm exit is a `shutdown-exit` error carrying the PID in its message, even when workload cleanup has already succeeded. This does not require persisted PID records or forceful termination. Caller cancellation ends its wait without cancelling admitted owner cleanup. @@ -503,7 +507,7 @@ sequenceDiagram A lost response does not prove failure. While the host lives, callers can inspect its instance observations and in-memory operation result; never blindly repeat creation or restoration. No operation journal or result history survives host death, and there is no command replay or exactly-once execution promise. -Attached tools have a separate contract. `tools.run` streams `started(jobId)`, stdout/stderr chunks and a terminal exit result. Optional input arrives through bounded `tools.writeStdin` calls and `tools.closeStdin`. These calls are restricted to the originating invocation/session. Cancelling or losing the execution stream terminates and cleans up that job. The public `stack.tools.run()` wrapper handles this exchange behind its stdin/stdout interface; no separate public launch/attach/wait workflow is required. The terminal success response follows output delivery and cleanup, with execution/cleanup failures reported as errors. +Attached commands have a separate contract. `runCommand` streams `started(jobId)`, stdout/stderr chunks and a terminal exit result. Optional input arrives through bounded `commandInput` calls; a final call closes stdin. These calls are restricted to the originating invocation/session. Cancelling or losing the execution stream terminates and cleans up that job. The public `stack.commands.run()` wrapper handles this exchange behind its stdin/stdout interface; no separate public launch/attach/wait workflow is required. The terminal success response follows output delivery and cleanup, with execution/cleanup failures reported as errors. ## 8. Keep safety infrastructure at its boundary @@ -584,7 +588,7 @@ A shared listener stays bound while any route is running or armed. When no route Standalone and composed instances use the same allocator. Two shadow databases have separate IDs, data and dedicated listeners. Composition membership does not change listener ownership. The runtime reports the backend address; the proxy updates its target without altering the public assignment. -The endpoint renderer produces host-facing or stack-runtime-facing connection values for `service.credentials({ from: "host" | "runtime" })` and for composition wiring. This is ordinary networking configuration, not a dependency-aware tool API. In container mode, both the rendered address and configured listener reachability must work from that network; report unsupported network configurations before executing the dependent/tool. +The endpoint renderer produces host-facing or stack-runtime-facing connection values for `service.credentials({ from: "host" | "runtime" })` and for composition wiring. This is ordinary networking configuration, not a dependency-aware command API. In container mode, both the rendered address and configured listener reachability must work from that network; report unsupported network configurations before executing the dependent/command. | Owner | Responsibility | | ------------------ | ----------------------------------------------------------------------------------------------------- | @@ -654,7 +658,7 @@ Keep the contract narrow: - Native snapshots copy or clone the host data; container snapshots copy database data through a managed volume and helper. Docker data normally lives in a managed volume, while existing host data can be retained through the host-backed fallback. The host storage marker detects a missing or mismatched Docker volume; deleting that volume loses its database data. - Restore transfers compatible database contents, not the source instance's identity, public port claims or composition membership. The target retains its own data location and configuration, with database-specific credentials reconciled before readiness. Snapshots survive destruction of the source instance because their managed storage is separate. -These are physical database snapshots for the cache use case. A `pg_dump` invocation remains an ordinary client tool for logical exports. CLI code owns cache keys, migrations and the decision to fall back to rebuilding a baseline; managed storage owns publication and retention. The snapshot API does not acquire CLI cache policy. +These are physical database snapshots for the cache use case. A `pg_dump` invocation remains an ordinary client command for logical exports. CLI code owns cache keys, migrations and the decision to fall back to rebuilding a baseline; managed storage owns publication and retention. The snapshot API does not acquire CLI cache policy. ### Resetting database data @@ -692,7 +696,7 @@ Update actual consumers together: | Functions serve | Launch/restart → await ready → follow logs and status; inspector flags are unsupported | | Proxy wake | Launch if sleeping → await ready → forward | | DB reset/cache | Stop selected dependents → stopped snapshot/restore or DB config work → launch/ready → resume dependents | -| Tools | Select identity/artifact → execute/stream → await exit and cleanup | +| Commands | Select identity/artifact → execute/stream → await exit and cleanup | A Functions runtime exit must reach `followStatus` so serve can report it. @@ -704,7 +708,7 @@ When `stack start` includes Functions, the CLI uses the existing package export The CLI owns the foreground `functions serve` session. It attaches to an existing composition member and leaves it available on exit. Supported explicit overrides replace its configuration for the session, then restore it on normal cleanup. If Functions is excluded, the CLI creates and later destroys one standalone instance without changing composition. The package needs no session or recovery API: ordinary create, start, restart, status, logs, and destroy suffice. Functions accepts custom environment values and a database URL; its recipe derives default keys, while the composer supplies the runtime database URL without a dependency edge. See the [command lifecycle](../../apps/cli/docs/stack-commands.md) for supported flags and cleanup limits. -PostgreSQL artifact knowledge remains in Stack and is exposed through [`postgres-artifact.ts`](./src/internal/postgres-artifact.ts), including catalog resolution and native artifact preparation and verification. A remote `db dump --db-url` can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by [`bundled-postgres-client.ts`](../../apps/cli/src/command-internal/bundled-postgres-client.ts), while managed jobs continue to use `stack.tools.run`. +PostgreSQL artifact knowledge remains in Stack and is exposed through [`postgres-artifact.ts`](./src/internal/postgres-artifact.ts), including catalog resolution and native artifact preparation and verification. A remote `db dump --db-url` can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by [`bundled-postgres-client.ts`](../../apps/cli/src/command-internal/bundled-postgres-client.ts), while managed jobs continue to use `stack.commands.run`. Changing internal and public-to-repository contracts is acceptable when callers are updated; preserving valuable data is still required. Keep per-instance configuration replacement through `service.restart({ config })`. Validate the candidate configuration and prepare its artifacts before stopping the existing runtime; invalid input must leave it running. The admitted restart then performs ordinary stop and launch without waiting for application health inside the gate. Adding or removing a companion means explicitly adding or removing an ordinary instance and updating composition edges. Validate the graph using the same rules as registration; do not implement private-child expansion or group replacement logic. Defer live shared configuration changes and config-bearing whole-stack restart. @@ -719,7 +723,7 @@ Changing internal and public-to-repository contracts is acceptable when callers | Durable operation journals and crash reconciliation | Remove; persist only normal stop/start definitions and resources | | Separate capability/stack lifecycle projections | Observe instance state directly; composition returns its member observations | | Separate whole-stack behavior | Selection over the same graph operations | -| Managed-local CLI tool ownership branches | Shared stack job execution | +| Managed-local CLI command ownership branches | Shared stack job execution | Before the package is considered complete or integrated with the final CLI, remove superseded package capability, supervisor, projection and journal implementations together with tests and exports that only served those implementations. The final package has one implementation path and no compatibility facade or parallel lifecycle implementation. @@ -738,4 +742,4 @@ Verify through consumed integration flows: 5. Unexpected exit disables wake and triggers ordinary cleanup of the owned runtime before another launch; Functions can explicitly restart afterward. Traffic cannot restart an exited prerequisite. A stale exit cannot damage a replacement. 6. Normal snapshot failures do not publish partial entries or overwrite existing data. Client disconnection leaves admitted snapshot work owned by the live host. Host-crash recovery is not an acceptance requirement; unavailable observations must not be presented as current running/healthy/stopped state. 7. Container-mode service wiring and dumps receive a reachable runtime-facing database URL as plain data. Dumps stream with bounded buffering and exact cleanup; stackless commands create no managed stack. Stopping/destroying REST leaves Auth on the shared API port working; shared port claims survive removal of the final route. -8. Namespace stop settles executing work and leaves no live runtime resources; failed cleanup cannot report successful stop. Disconnecting a client does not stop admitted lifecycle/snapshot operations. Stopping the last individual instance or finishing the last tool does not retire the StackHost; namespace stop/destroy does. +8. Namespace stop settles executing work and leaves no live runtime resources; failed cleanup cannot report successful stop. Disconnecting a client does not stop admitted lifecycle/snapshot operations. Stopping the last individual instance or finishing the last command does not retire the StackHost; namespace stop/destroy does. diff --git a/packages/stack/README.md b/packages/stack/README.md index f0cd1a40a7..b60143a22e 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -9,7 +9,8 @@ The package accepts service configuration directly. Loading CLI configuration, m The Promise entrypoint accepts plain configuration: ```ts -import { create, postgres } from "@supabase/stack"; +import { create } from "@supabase/stack"; +import { postgres } from "@supabase/stack/commands"; const stack = await create({ projectRoot: process.cwd(), @@ -34,7 +35,7 @@ await database.ready(); // initialization and health completed const { databaseUrl } = await database.credentials({ from: "runtime" }); if (databaseUrl === undefined) throw new Error("Database endpoint missing"); -await stack.tools.run(postgres.psql({ major: 17 }), { +await stack.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "--command", "SELECT 1"], stdout: (bytes) => { process.stdout.write(bytes); @@ -44,6 +45,11 @@ await stack.tools.run(postgres.psql({ major: 17 }), { }, }); +// Runs recipe initialization without registering a service instance. +const { authDatabaseUrl } = await database.credentials({ from: "runtime" }); +if (authDatabaseUrl === undefined) throw new Error("Auth database URL missing"); +await stack.commands.run({ type: "auth.initialize", databaseUrl: authDatabaseUrl }); + await database.stop(); await database.saveSnapshot("baseline"); await stack.close(); // disconnects this client @@ -67,7 +73,7 @@ Pass `startOwner: true` to `open` when live status and other owner-backed operat `destroy` normally returns `{ runtimeCleanup: "complete" }`. When no owner is running and the stack's container engine reports that its daemon cannot be reached, `destroy` removes the local registration and host data anyway and returns `{ runtimeCleanup: "skipped", engine, cleanupCommands }`; its containers and any database data in engine volumes remain, and `cleanupCommands` are the shell commands that remove them once the engine is running. If some host data cannot be deleted by the current user, `destroy` fails before removing anything so it can be retried with the engine running. -The stack owns database, Functions bootstrap, and tool-job directories below its data directory. Storage uploads remain at the caller-supplied Storage `filePath` and are preserved when the stack is destroyed; the caller owns that directory. Host metadata remains under `stateRoot`; native database data uses host files. Docker database data normally uses a managed volume, while existing host data is retained through the host-backed fallback. A host marker records the selected Docker storage and detects a missing or mismatched volume; deleting that volume loses the associated database data. Native snapshot entries live below `cacheRoot`. Docker snapshots share the managed data volume in a separate namespace derived from `cacheRoot`, so they survive source destruction and can use filesystem cloning. A Docker cache hit requires the same daemon, `stateRoot`, and `cacheRoot`. There is no portable tar snapshot API. +The stack owns database, Functions bootstrap, and command-job directories below its data directory. Storage uploads remain at the caller-supplied Storage `filePath` and are preserved when the stack is destroyed; the caller owns that directory. Host metadata remains under `stateRoot`; native database data uses host files. Docker database data normally uses a managed volume, while existing host data is retained through the host-backed fallback. A host marker records the selected Docker storage and detects a missing or mismatched volume; deleting that volume loses the associated database data. Native snapshot entries live below `cacheRoot`. Docker snapshots share the managed data volume in a separate namespace derived from `cacheRoot`, so they survive source destruction and can use filesystem cloning. A Docker cache hit requires the same daemon, `stateRoot`, and `cacheRoot`. There is no portable tar snapshot API. Omitted database `jwtSecret` and `rootKey` inputs use the shared local-development values exported as `DEFAULT_LOCAL_JWT_SECRET` and `DEFAULT_POSTGRES_ROOT_KEY`. Explicit values override these defaults. @@ -135,7 +141,7 @@ Bindings supply ordinary configuration values; a URL alone never creates a depen - Instance methods affect that instance, subject to dependency checks. - Composition methods affect selected members; startup also includes declared prerequisites. -- `stack.stop()` stops every owned instance and attached tool and returns after confirming owner exit. It requires a live owner; an unavailable owner cannot confirm cleanup. +- `stack.stop()` stops every owned instance and attached command and returns after confirming owner exit. It requires a live owner; an unavailable owner cannot confirm cleanup. - `stack.destroy()` additionally removes owned data and registrations, and also waits for owner exit. - `stack.close()` disposes the client and invalidates its active observation iterators. Stopping the last instance leaves the owner available. @@ -155,7 +161,7 @@ try { } ``` -Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observations include the currently bound public endpoints, including listeners for sleeping services. Credentials default to host addressing. Use `from: "runtime"` for a URL passed to a service or tool container. +Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observations include the currently bound public endpoints, including listeners for sleeping services. Credentials default to host addressing. Use `from: "runtime"` for a URL passed to a service or command container. ## Effect consumers @@ -179,7 +185,7 @@ await Effect.runPromise( ); ``` -Cancelling an admitted lifecycle caller ends its wait; the owner finishes the operation. Cancelling an attached tool ends that job and cleans up its resources. Tool input and output stream with backpressure; the result contains a job ID and exit code, not collected output. Promise tool sinks should return a Promise when the destination requires waiting for capacity. +Cancelling an admitted lifecycle caller ends its wait; the owner finishes the operation. Cancelling an attached command ends that job and cleans up its resources. Command input and output stream with backpressure; the result contains a job ID and exit code, not collected output. Promise output sinks should return a Promise when the destination requires waiting for capacity. For disposable Effect test fixtures, `acquireUseRelease` runs teardown on failure and interruption while retaining typed cleanup errors: diff --git a/packages/stack/package.json b/packages/stack/package.json index bae03af447..e889bf9666 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -7,7 +7,7 @@ ".": "./src/index.ts", "./effect": "./src/effect.ts", "./defaults": "./src/Defaults.ts", - "./tools": "./src/Tools.ts", + "./commands": "./src/Commands.ts", "./internal/dispatch": "./src/internal/dispatch.ts", "./internal/identity": "./src/identity/Identity.ts", "./internal/functions/serve-main": "./src/functions/serve.main.ts", diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index c36d99f2b0..d2f8c1ed1c 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -85,7 +85,7 @@ const definitions: Readonly> = { realtime: definition( "realtime", "v2.134.5", - "ghcr.io/supabase/cli/realtime:v2.134.5@sha256:7fb53cc6987085d739c7d161608505ed138d1895df884c3bdc2147cef444138a", + "ghcr.io/supabase/cli/realtime:v2.134.5@sha256:ee672ffd06ca0a1712a06aea1307c134c366ec082a51b051a8d59a8ad0c72755", "bin/server", ["bin/server", "bin/prepare"], ), diff --git a/packages/stack/src/Tools.integration.test.ts b/packages/stack/src/Commands.integration.test.ts similarity index 76% rename from packages/stack/src/Tools.integration.test.ts rename to packages/stack/src/Commands.integration.test.ts index f03ca504d2..7b5f9cc8d9 100644 --- a/packages/stack/src/Tools.integration.test.ts +++ b/packages/stack/src/Commands.integration.test.ts @@ -11,13 +11,16 @@ import { Predicate, Redacted, Ref, + Scope, Schedule, Stream, } from "effect"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { postgres } from "./Tools.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; +import { postgres } from "./Commands.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; +import { CommandError } from "./host/CommandRunner.ts"; +import type { PgProveOptions, PostgresCommand } from "./Commands.ts"; import { makeDatabase } from "./services/Database.ts"; import { makeService } from "./Service.ts"; import { bindTcp, serveTcp } from "./Proxy.ts"; @@ -28,17 +31,47 @@ class PgProveTestError extends Data.TaggedError("PgProveTestError")<{ readonly message: string; }> {} -const makeTestToolRunner = (options: { +const makeTestCommandRunner = (options: { readonly stackId: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; }) => - Layer.build(ToolRunner.layer(options)).pipe( - Effect.map((context) => Context.get(context, ToolRunner.Service)), + Layer.build(CommandRunner.layer(options)).pipe( + Effect.map((context) => Context.get(context, CommandRunner.Service)), ); -describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { +const runPostgres = ( + runner: CommandRunner.Interface, + input: { + readonly postgres: PostgresCommand; + readonly args: ReadonlyArray; + readonly env: Readonly>; + readonly pgProve?: PgProveOptions; + readonly stdin?: Stream.Stream; + readonly stdout: (bytes: Uint8Array) => Effect.Effect; + readonly stderr: (bytes: Uint8Array) => Effect.Effect; + }, +): Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + CommandError, + Scope.Scope +> => + runner.run({ + command: { + type: "postgres", + command: input.postgres, + args: input.args, + env: input.env, + ...(input.pgProve === undefined ? {} : { pgProve: input.pgProve }), + stdin: input.stdin !== undefined, + }, + stdin: input.stdin, + stdout: input.stdout, + stderr: input.stderr, + }); + +describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { for (const runtime of ["native", "docker"] as const) { it.live(`${runtime} runs SQL from stdin, streams a dump, and returns client failure`, () => Effect.scoped( @@ -74,7 +107,7 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { endpoint.kind === "unix" ? { path: `${endpoint.path}/.s.PGSQL.5432` } : endpoint, ), ).pipe(Effect.forkScoped); - const runner = yield* makeTestToolRunner({ root, cacheRoot, stackId, runtime }); + const runner = yield* makeTestCommandRunner({ root, cacheRoot, stackId, runtime }); const env = { PGHOST: runtime === "native" ? "127.0.0.1" : "host.docker.internal", PGPORT: String(listener.address.port), @@ -90,8 +123,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { stderr: (bytes: Uint8Array) => Ref.update(stderr, (text) => text + new TextDecoder().decode(bytes)), }; - const sql = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const sql = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: ["-X", "-v", "ON_ERROR_STOP=1"], env, stdin: Stream.make( @@ -104,8 +137,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(sql.exitCode).toBe(0); expect(yield* Ref.get(stdout)).toContain("streamed-row"); yield* Ref.set(stdout, ""); - const dump = yield* runner.run({ - tool: postgres.pgDump({ major: 17 }), + const dump = yield* runPostgres(runner, { + postgres: postgres.pgDump({ major: 17 }), args: ["--data-only", "--table=tool_story"], env, ...output, @@ -114,8 +147,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(dump.jobId).not.toBe(sql.jobId); expect(yield* Ref.get(stdout)).toContain("COPY public.tool_story"); expect(yield* Ref.get(stdout)).toContain("streamed-row"); - const failed = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const failed = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: ["-X", "-v", "ON_ERROR_STOP=1", "-c", "SELECT missing_column FROM tool_story"], env, ...output, @@ -123,18 +156,18 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(failed.exitCode).not.toBe(0); expect(yield* Ref.get(stderr)).toContain("missing_column"); const rejected = yield* Effect.flip( - runner.run({ - tool: postgres.psql({ major: 17 }), + runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: [], env, pgProve: { mounts: [] }, ...output, }), ); - expect(rejected.message).toContain("pgProve options require the pg_prove tool"); + expect(rejected.message).toContain("pgProve options require the pg_prove command"); yield* Ref.set(stderr, ""); - const early = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const early = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: ["-X", "-v", "ON_ERROR_STOP=1"], env, stdin: Stream.make( @@ -147,26 +180,24 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(early.exitCode).not.toBe(0); expect(yield* Ref.get(stderr)).toContain("missing_column"); const attached = yield* Deferred.make(); - const job = yield* runner - .run({ - tool: postgres.psql({ major: 17 }), - args: ["-X", "-q", "-t", "-A"], - env: { ...env, PGAPPNAME: "attached-tool-story" }, - stdin: Stream.make(new TextEncoder().encode("SELECT 'attached-ready';\n")).pipe( - Stream.concat(Stream.never), - ), - stdout: (bytes) => - new TextDecoder().decode(bytes).includes("attached-ready") - ? Deferred.succeed(attached, undefined).pipe(Effect.asVoid) - : Effect.void, - stderr: output.stderr, - }) - .pipe(Effect.forkChild); + const job = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), + args: ["-X", "-q", "-t", "-A"], + env: { ...env, PGAPPNAME: "attached-tool-story" }, + stdin: Stream.make(new TextEncoder().encode("SELECT 'attached-ready';\n")).pipe( + Stream.concat(Stream.never), + ), + stdout: (bytes) => + new TextDecoder().decode(bytes).includes("attached-ready") + ? Deferred.succeed(attached, undefined).pipe(Effect.asVoid) + : Effect.void, + stderr: output.stderr, + }).pipe(Effect.forkChild); yield* Deferred.await(attached); yield* Fiber.interrupt(job); yield* Ref.set(stdout, ""); - const remaining = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const remaining = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: [ "-X", "-t", @@ -222,7 +253,7 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { endpoint.kind === "unix" ? { path: `${endpoint.path}/.s.PGSQL.5432` } : endpoint, ), ).pipe(Effect.forkScoped); - const runner = yield* makeTestToolRunner({ root, cacheRoot, stackId, runtime }); + const runner = yield* makeTestCommandRunner({ root, cacheRoot, stackId, runtime }); const env = { PGHOST: runtime === "native" ? "127.0.0.1" : "host.docker.internal", PGPORT: String(listener.address.port), @@ -246,16 +277,16 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { stderr: (bytes: Uint8Array) => Ref.update(stderr, (text) => text + new TextDecoder().decode(bytes)), }; - const extension = yield* runner.run({ - tool: postgres.psql({ major }), + const extension = yield* runPostgres(runner, { + postgres: postgres.psql({ major }), args: ["-X", "-v", "ON_ERROR_STOP=1", "-c", "CREATE EXTENSION IF NOT EXISTS pgtap"], env, ...output, }); expect(extension.exitCode).toBe(0); const pgProve = (file: string) => - runner.run({ - tool: postgres.pgProve({ major }), + runPostgres(runner, { + postgres: postgres.pgProve({ major }), args: ["--ext", ".sql", file, "--verbose"], env, pgProve: { @@ -295,34 +326,32 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { "SELECT plan(1);\nSELECT pass('pgprove-idle');\n\\watch 60\n", ); const cancelOutput = yield* Ref.make(""); - const running = yield* runner - .run({ - tool: postgres.pgProve({ major }), - args: ["--ext", ".sql", "cancel-ready.sql", "cancel.sql", "--verbose"], - env: { ...env, PGAPPNAME: applicationName }, - pgProve: { - mounts: [{ source: tests, target: "/tests" }], - cwd: tests, - workingDir: "/tests", - }, - stdout: (bytes) => - Effect.gen(function* () { - yield* Ref.update( - cancelOutput, - (text) => text + new TextDecoder().decode(bytes), - ); - if ((yield* Ref.get(cancelOutput)).includes("pgprove-ready")) - yield* Deferred.succeed(ready, undefined); - }), - stderr: output.stderr, - }) - .pipe(Effect.forkChild); + const running = yield* runPostgres(runner, { + postgres: postgres.pgProve({ major }), + args: ["--ext", ".sql", "cancel-ready.sql", "cancel.sql", "--verbose"], + env: { ...env, PGAPPNAME: applicationName }, + pgProve: { + mounts: [{ source: tests, target: "/tests" }], + cwd: tests, + workingDir: "/tests", + }, + stdout: (bytes) => + Effect.gen(function* () { + yield* Ref.update( + cancelOutput, + (text) => text + new TextDecoder().decode(bytes), + ); + if ((yield* Ref.get(cancelOutput)).includes("pgprove-ready")) + yield* Deferred.succeed(ready, undefined); + }), + stderr: output.stderr, + }).pipe(Effect.forkChild); yield* Deferred.await(ready); const idleOutput = yield* Ref.make(""); const idle = Effect.gen(function* () { yield* Ref.set(idleOutput, ""); - const probe = yield* runner.run({ - tool: postgres.psql({ major }), + const probe = yield* runPostgres(runner, { + postgres: postgres.psql({ major }), args: [ "-X", "-t", @@ -347,8 +376,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { const remainingOutput = yield* Ref.make(""); const remaining = Effect.gen(function* () { yield* Ref.set(remainingOutput, ""); - const probe = yield* runner.run({ - tool: postgres.psql({ major }), + const probe = yield* runPostgres(runner, { + postgres: postgres.psql({ major }), args: [ "-X", "-t", diff --git a/packages/stack/src/Commands.ts b/packages/stack/src/Commands.ts new file mode 100644 index 0000000000..05ed207925 --- /dev/null +++ b/packages/stack/src/Commands.ts @@ -0,0 +1,116 @@ +import { Schema } from "effect"; + +const PgProveMount = Schema.Struct({ + source: Schema.String, + target: Schema.String, +}); + +export const PgProveOptions = Schema.Struct({ + mounts: Schema.Array(PgProveMount), + cwd: Schema.optional(Schema.String), + workingDir: Schema.optional(Schema.String), +}); +export interface PgProveOptions extends Schema.Schema.Type {} + +export const PostgresCommand = Schema.Struct({ + command: Schema.Literals(["pg_dump", "pg_dumpall", "pg_prove", "psql"]), + major: Schema.Literals([15, 17]), +}); +export interface PostgresCommand extends Schema.Schema.Type {} + +const AuthInitializationCommand = Schema.Struct({ + type: Schema.Literal("auth.initialize"), + version: Schema.optional(Schema.String), + databaseUrl: Schema.String, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +const StorageInitializationCommand = Schema.Struct({ + type: Schema.Literal("storage.initialize"), + version: Schema.optional(Schema.String), + databaseUrl: Schema.String, + filePath: Schema.String, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +const RealtimeInitializationCommand = Schema.Struct({ + type: Schema.Literal("realtime.initialize"), + version: Schema.optional(Schema.String), + databaseUrl: Schema.String, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); + +/** A finite service setup action with inputs owned by its service definition. */ +export const InitializationCommand = Schema.Union([ + AuthInitializationCommand, + StorageInitializationCommand, + RealtimeInitializationCommand, +]); +export type InitializationCommand = Schema.Schema.Type; + +/** A finite action that runs to completion and does not own service lifecycle. */ +export const Command = Schema.Union([PostgresCommand, InitializationCommand]); +export type Command = Schema.Schema.Type; + +const PostgresInvocation = Schema.Struct({ + type: Schema.Literal("postgres"), + command: PostgresCommand, + args: Schema.Array(Schema.String), + env: Schema.Record(Schema.String, Schema.String), + pgProve: Schema.optional(PgProveOptions), + stdin: Schema.Boolean, +}); + +/** A command invocation serialized through the stack host attachment. */ +export const CommandInvocation = Schema.Union([ + PostgresInvocation, + AuthInitializationCommand, + StorageInitializationCommand, + RealtimeInitializationCommand, +]); +export type CommandInvocation = Schema.Schema.Type; + +export interface ResolvedCommand { + readonly service: "auth" | "storage" | "realtime"; + readonly version: string; + readonly image: string; + readonly nativeExecutable: string; + readonly containerEntrypoint: string; + readonly args: ReadonlyArray; + readonly env: Readonly>; + readonly cwd?: string; + readonly workingDir?: string; + readonly mounts: ReadonlyArray<{ + readonly source: string; + readonly target: string; + readonly readOnly: boolean; + }>; +} + +/** Describes PostgreSQL clients without managing a database service. */ +export const postgres = { + pgDump: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "pg_dump", + major, + }), + pgDumpAll: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "pg_dumpall", + major, + }), + pgProve: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "pg_prove", + major, + }), + psql: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "psql", + major, + }), +}; + +export const initialization = { + auth: (input: Omit, "type">) => ({ + type: "auth.initialize" as const, + ...input, + }), + storage: ( + input: Omit, "type">, + ) => ({ type: "storage.initialize" as const, ...input }), + realtime: ( + input: Omit, "type">, + ) => ({ type: "realtime.initialize" as const, ...input }), +}; diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 3d77a54788..2bacd218a9 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -58,6 +58,42 @@ const query = (url: string, statement: string) => }), ); +it.effect("credential lookup leaves a fresh stack untouched for custom database credentials", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-credentials-" }); + const stack = initial("owner-credentials"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const owner = yield* ownerFor({ + saved: stack, + state, + root: `${root}/data`, + cacheRoot, + }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + + const failure = yield* owner.getStackCredentials.pipe(Effect.flip); + expect(failure.message).toContain("have not been established"); + expect((yield* state.read(stack.id))?.credentials).toBeUndefined(); + + const jwtSecret = "custom-owner-credentials-jwt-secret-long-enough"; + yield* owner.services.create({ + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("owner-credentials-database-password"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }); + expect((yield* state.read(stack.id))?.credentials?.jwtSecret).toBe(jwtSecret); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live("forwards and rotates saved identity across composed services in one owner", () => Effect.scoped( Effect.gen(function* () { @@ -139,6 +175,7 @@ it.live("forwards and rotates saved identity across composed services in one own .pipe(Effect.map((saved) => saved?.credentials)); if (firstCredentials === undefined) return yield* Effect.die("identity was not persisted"); expect(firstCredentials.jwtSecret).toBe(customJwtSecret); + expect(yield* owner.getStackCredentials).toEqual(firstCredentials); const firstRest = creationFor(first, "rest"); const firstStorage = creationFor(first, "storage"); const firstRealtime = creationFor(first, "realtime"); diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index c61728f889..c5a4b86d81 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -120,6 +120,7 @@ export interface Interface { id: string, from: "host" | "runtime", ) => Effect.Effect>, OwnerError>; + readonly getStackCredentials: Effect.Effect; readonly snapshots: { readonly saveSnapshot: (id: string, key: string) => Effect.Effect; readonly restoreSnapshot: (id: string, key: string) => Effect.Effect; @@ -393,6 +394,18 @@ const makeOwnerWithDependencies = ( return credentials; }); + const getStackCredentials = options.state.read(options.saved.id).pipe( + Effect.mapError((cause) => errorFor("credentials", cause)), + Effect.flatMap((current) => + current === undefined + ? errorFor("credentials", "Saved stack is missing") + : current.credentials === undefined + ? errorFor("credentials", "Stack credentials have not been established") + : Effect.succeed(current.credentials), + ), + Effect.withSpan("Owner.getStackCredentials"), + ); + const resolveCredentials = Effect.fn("Owner.resolveCredentials")(function* (input: unknown) { const creation = yield* Schema.decodeUnknownEffect(ServiceCreationInput)(input).pipe( Effect.mapError((cause) => errorFor("config", cause)), @@ -1402,6 +1415,7 @@ const makeOwnerWithDependencies = ( restart: compositionRestart, }, credentials, + getStackCredentials, snapshots: { saveSnapshot, restoreSnapshot, diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 6dab524166..16352cf3e1 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -2,7 +2,7 @@ import { Data, Schema } from "effect"; import { Rpc, RpcGroup } from "effect/unstable/rpc"; import { ServiceCreation, ServiceCreationInput } from "./services/Catalog.ts"; import { CompositionConfig } from "./Orchestrator.ts"; -import { PgProveOptions, PostgresTool } from "./Tools.ts"; +import { CommandInvocation } from "./Commands.ts"; import { StackIdentityInput } from "./State.ts"; const Outcome = Schema.Struct({ @@ -61,12 +61,17 @@ const Log = Schema.Struct({ bytes: Schema.Uint8ArrayFromBase64, }); -export const ToolEvent = Schema.TaggedUnion({ +export const CommandEvent = Schema.TaggedUnion({ Attached: { attachmentId: Schema.String }, Stdout: { bytes: Schema.Uint8ArrayFromBase64 }, Stderr: { bytes: Schema.Uint8ArrayFromBase64 }, Completed: { jobId: Schema.String, exitCode: Schema.Int }, }); +export const RunCommandPayload = Schema.Struct({ + attachmentId: Schema.String, + command: CommandInvocation, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type RunCommandPayload = Schema.Schema.Type; /** The private transport contract; lifecycle admission remains in the owner. */ export const StackRpc = RpcGroup.make( @@ -124,20 +129,13 @@ export const StackRpc = RpcGroup.make( Rpc.make("stopComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), Rpc.make("restartComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), Rpc.make("shutdown", { payload: { destroy: Schema.Boolean }, error: StackErrorSchema }), - Rpc.make("runTool", { - payload: { - attachmentId: Schema.String, - tool: PostgresTool, - args: Schema.Array(Schema.String), - env: Schema.Record(Schema.String, Schema.String), - pgProve: Schema.optionalKey(PgProveOptions), - stdin: Schema.Boolean, - }, - success: ToolEvent, + Rpc.make("runCommand", { + payload: RunCommandPayload, + success: CommandEvent, error: StackErrorSchema, stream: true, }), - Rpc.make("toolInput", { + Rpc.make("commandInput", { payload: { attachmentId: Schema.String, bytes: Schema.NullOr(Schema.Uint8ArrayFromBase64) }, error: StackErrorSchema, }), diff --git a/packages/stack/src/Service.ts b/packages/stack/src/Service.ts index 70f3978db5..a9f16d5ba1 100644 --- a/packages/stack/src/Service.ts +++ b/packages/stack/src/Service.ts @@ -458,6 +458,7 @@ export const makeService = ( wake = false, guard: Effect.Effect = Effect.void, ) { + yield* Effect.annotateCurrentSpan({ member_id: options.id }); let existing = yield* SubscriptionRef.get(observations); if (!existing.registered) return yield* new ServiceDestroyed({ id: options.id }); if (existing.lifecycle === "running") return; @@ -589,6 +590,7 @@ export const makeService = ( return yield* staleLaunch(launchId, `Service ${options.id} stopped before it was ready`); }); const ready = Effect.fn("Service.ready")(function* () { + yield* Effect.annotateCurrentSpan({ member_id: options.id }); const initial = yield* SubscriptionRef.get(observations); if (!initial.registered) return yield* new ServiceDestroyed({ id: options.id }); const expectedRevision = yield* Ref.get(revision); diff --git a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts index cea6a7965b..7950c4dc0e 100644 --- a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts +++ b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts @@ -6,7 +6,7 @@ import { StackRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import { acquireHost } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; import { makeRuntime } from "./StackHost.ts"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; @@ -89,7 +89,7 @@ it.live("retains saved state when destroy sweep fails and retries after engine r const owner = baseOwner; const acquired = yield* acquireHost(state, saved.id); const runnerLayer = yield* Layer.build( - ToolRunner.layer({ + CommandRunner.layer({ stackId: saved.id, root: `${root}/data`, cacheRoot: `${root}/cache`, @@ -108,7 +108,10 @@ it.live("retains saved state when destroy sweep fails and retries after engine r acquired.closeConnections, ownership, ).pipe( - Effect.provideService(ToolRunner.Service, Context.get(runnerLayer, ToolRunner.Service)), + Effect.provideService( + CommandRunner.Service, + Context.get(runnerLayer, CommandRunner.Service), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, engine), ); yield* runtime.serve; diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 2bff7a3d01..90ec983139 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -12,9 +12,10 @@ import { Layer, Redacted, Ref, + Schema, Stream, } from "effect"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/unstable/rpc"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { ChildProcessSpawner } from "effect/unstable/process"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration observes exact listener closure. @@ -23,11 +24,11 @@ import { acquireHost, launchHost } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import { OwnerError } from "./Owner.ts"; import { OrchestratorError } from "./Orchestrator.ts"; -import { StackRpc } from "./Rpc.ts"; +import { CommandEvent, StackErrorSchema, StackRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import { makeRuntime } from "./StackHost.ts"; -import { postgres } from "./Tools.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; +import { postgres } from "./Commands.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; class HostTestError extends Data.TaggedError("HostTestError")<{ readonly message: string }> {} @@ -64,6 +65,24 @@ const clientFor = (port: number) => ), ); +const permissiveCommandClientFor = (port: number) => + RpcClient.make( + RpcGroup.make( + Rpc.make("runCommand", { + payload: Schema.Unknown, + success: CommandEvent, + error: StackErrorSchema, + stream: true, + }), + ), + ).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( + Layer.provide(RpcSerialization.layerNdjson), + ), + ), + ); + const openIdleSocket = (port: number) => Effect.callback((resume) => { let socket: Net.Socket; @@ -124,7 +143,7 @@ const inProcessRuntime = ( Effect.gen(function* () { const acquired = yield* acquireHost(state, "stack"); const toolContext = yield* Layer.build( - ToolRunner.layer({ + CommandRunner.layer({ stackId: "stack", root, cacheRoot: "/tmp/supabase-stack-artifacts", @@ -142,7 +161,9 @@ const inProcessRuntime = ( acquired.server, acquired.closeConnections, options?.container, - ).pipe(Effect.provideService(ToolRunner.Service, Context.get(toolContext, ToolRunner.Service))); + ).pipe( + Effect.provideService(CommandRunner.Service, Context.get(toolContext, CommandRunner.Service)), + ); const runtime = yield* options?.spawner === undefined ? runtimeEffect : runtimeEffect.pipe( @@ -488,6 +509,34 @@ it.live( const identity = yield* http.get(`http://127.0.0.1:${endpoint.port}/identity`); expect(identity.status).toBe(200); const client = yield* clientFor(endpoint.port); + const permissiveClient = yield* permissiveCommandClientFor(endpoint.port); + for (const override of ["args", "env", "pgProve", "stdin"] as const) { + const rejected = yield* permissiveClient + .runCommand({ + attachmentId: `invalid-${override}`, + command: { type: "auth.initialize", databaseUrl: "postgres://localhost/postgres" }, + [override]: override === "args" ? [] : {}, + }) + .pipe(Stream.runDrain, Effect.exit); + expect(Exit.isFailure(rejected)).toBe(true); + if (Exit.isFailure(rejected)) + expect(Cause.pretty(rejected.cause)).toContain("Expected no excess property"); + } + for (const override of ["args", "env", "pgProve", "stdin"] as const) { + const rejected = yield* permissiveClient + .runCommand({ + attachmentId: `invalid-command-${override}`, + command: { + type: "auth.initialize", + databaseUrl: "postgres://localhost/postgres", + [override]: override === "args" ? [] : {}, + }, + }) + .pipe(Stream.runDrain, Effect.exit); + expect(Exit.isFailure(rejected)).toBe(true); + if (Exit.isFailure(rejected)) + expect(Cause.pretty(rejected.cause)).toContain("Expected no excess property"); + } const stopped = yield* Ref.make(false); yield* Effect.addFinalizer(() => Ref.get(stopped).pipe( @@ -506,22 +555,25 @@ it.live( expect(mail.creation.service).toBe("mail"); expect((yield* client.listServices()).length).toBe(1); const toolAttachment = "early-stdin"; - const toolEvents = yield* client - .runTool({ + const commandEvents = yield* client + .runCommand({ attachmentId: toolAttachment, - tool: postgres.psql({ major: 17 }), - args: ["--version"], - env: {}, - stdin: true, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: ["--version"], + env: {}, + stdin: true, + }, }) .pipe(Stream.runCollect); - expect(Array.from(toolEvents).some((event) => event._tag === "Completed")).toBe(true); + expect(Array.from(commandEvents).some((event) => event._tag === "Completed")).toBe(true); const closedInput = yield* client - .toolInput({ attachmentId: toolAttachment, bytes: null }) + .commandInput({ attachmentId: toolAttachment, bytes: null }) .pipe(Effect.flip); expect("operation" in closedInput).toBe(true); if (!("operation" in closedInput)) return yield* Effect.die("Unexpected RPC error"); - expect(closedInput.operation).toBe("tool-input-closed"); + expect(closedInput.operation).toBe("command-input-closed"); yield* client.startService({ id: mail.id }); yield* client.readyService({ id: mail.id }); expect((yield* client.status({ id: mail.id })).lifecycle).toBe("running"); @@ -555,20 +607,23 @@ it.live( const ready = yield* Deferred.make(); const drainingTool = yield* Effect.forkScoped( client - .runTool({ + .runCommand({ attachmentId: "draining-stdin", - tool: postgres.psql({ major: 17 }), - args: [ - "-X", - "-t", - "-A", - "-c", - "SELECT 'stack-host-tool-ready'", - "-c", - "SELECT pg_sleep(600)", - ], - env: databaseEnv, - stdin: true, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: [ + "-X", + "-t", + "-A", + "-c", + "SELECT 'stack-host-command-ready'", + "-c", + "SELECT pg_sleep(600)", + ], + env: databaseEnv, + stdin: true, + }, }) .pipe( Stream.filter((event) => event._tag === "Stdout"), @@ -576,7 +631,9 @@ it.live( Stream.decodeText, Stream.splitLines, Stream.runForEach((line) => - line === "stack-host-tool-ready" ? Deferred.succeed(ready, undefined) : Effect.void, + line === "stack-host-command-ready" + ? Deferred.succeed(ready, undefined) + : Effect.void, ), ), ); diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index f82b331397..e4fec07885 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -26,10 +26,10 @@ import { acquireHost, HostEndpoint } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import { OwnerError } from "./Owner.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import { StackError, StackRpc } from "./Rpc.ts"; +import { StackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; import * as State from "./State.ts"; -import { makeToolAttachments, type ToolAttachmentPayload } from "./host/ToolAttachments.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; +import { makeCommandAttachments } from "./host/CommandAttachments.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; import * as Container from "./runtime/Container.ts"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { CatalogLog } from "./services/Catalog.ts"; @@ -140,18 +140,38 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( ): Effect.Effect< StackHostRuntime, never, - Scope.Scope | ToolRunner.Service | ChildProcessSpawner.ChildProcessSpawner + Scope.Scope | CommandRunner.Service | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { const scope = yield* Scope.Scope; - const runner = yield* ToolRunner.Service; + const runner = yield* CommandRunner.Service; const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const attachments = yield* makeToolAttachments({ + const attachments = yield* makeCommandAttachments({ admit: owner.getServing.pipe( Effect.flatMap(isOpen), Effect.mapError((cause) => stackError("host", cause)), ), - run: runner.run, + run: (input) => + "stdin" in input + ? runner.run({ + command: input.command, + stdin: input.stdin, + stdout: input.stdout, + stderr: input.stderr, + }) + : owner.getStackCredentials.pipe( + Effect.mapError( + (cause) => new CommandRunner.CommandError({ message: cause.message, cause }), + ), + Effect.flatMap((credentials) => + runner.run({ + command: input.command, + credentials, + stdout: input.stdout, + stderr: input.stderr, + }), + ), + ), toError: stackError, }); const exit = yield* Deferred.make(); @@ -398,14 +418,14 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( onSome: (value) => shutdown(destroy, NodeHttpServerRequest.toServerResponse(value)), }); }).pipe(Effect.mapError((cause) => stackError("shutdown", cause))), - runTool: (input: ToolAttachmentPayload) => attachments.run(input), - toolInput: ({ + runCommand: (input: RunCommandPayload) => attachments.run(input), + commandInput: ({ attachmentId, bytes, }: { readonly attachmentId: string; readonly bytes: Uint8Array | null; - }) => attachments.input(attachmentId, true, bytes), + }) => attachments.input(attachmentId, bytes), }; const rpc = yield* RpcServer.toHttpEffect(StackRpc, { streamBufferSize: 16 }).pipe( Effect.provide(Layer.merge(StackRpc.toLayer(handlers), RpcSerialization.layerNdjson)), @@ -473,7 +493,7 @@ export const runStackHost = Effect.fn("StackHost.run")( root: dataRoot, cacheRoot: options.cacheRoot, }), - ToolRunner.layer({ + CommandRunner.layer({ stackId: saved.id, root: dataRoot, cacheRoot: options.cacheRoot, @@ -497,7 +517,10 @@ export const runStackHost = Effect.fn("StackHost.run")( ? undefined : { engine: saved.runtime, stackId: saved.id, root: dataRoot }, ).pipe( - Effect.provideService(ToolRunner.Service, Context.get(services, ToolRunner.Service)), + Effect.provideService( + CommandRunner.Service, + Context.get(services, CommandRunner.Service), + ), ); yield* runtime.serve; yield* options.onReady?.(endpoint) ?? Effect.void; diff --git a/packages/stack/src/Tools.ts b/packages/stack/src/Tools.ts deleted file mode 100644 index 3df6c0d646..0000000000 --- a/packages/stack/src/Tools.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Schema } from "effect"; - -const PgProveMount = Schema.Struct({ - source: Schema.String, - target: Schema.String, -}); - -export const PgProveOptions = Schema.Struct({ - mounts: Schema.Array(PgProveMount), - cwd: Schema.optional(Schema.String), - workingDir: Schema.optional(Schema.String), -}); -export interface PgProveOptions extends Schema.Schema.Type {} - -export const PostgresTool = Schema.Struct({ - command: Schema.Literals(["pg_dump", "pg_dumpall", "pg_prove", "psql"]), - major: Schema.Literals([15, 17]), -}); -export interface PostgresTool extends Schema.Schema.Type {} - -/** Describes finite PostgreSQL clients without managing a database service. */ -export const postgres = { - pgDump: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ command: "pg_dump", major }), - pgDumpAll: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ - command: "pg_dumpall", - major, - }), - pgProve: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ - command: "pg_prove", - major, - }), - psql: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ command: "psql", major }), -}; diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 7bb6174bee..92bb264c2c 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -2,14 +2,8 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, expectTypeOf, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Schema } from "effect"; import { tmpdir } from "node:os"; -import { - create, - discover, - open, - postgres, - type DatabaseInstance, - type ServiceInstance, -} from "./effect.ts"; +import { create, discover, open, type DatabaseInstance, type ServiceInstance } from "./effect.ts"; +import { initialization, postgres } from "./Commands.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); @@ -221,6 +215,17 @@ const resetDataStory = (runtime: "native" | "docker") => if (targetUrl === undefined || siblingUrl === undefined) return yield* Effect.die("database credentials missing"); + if (runtime === "native") { + const existingServices = yield* current.services.list; + const initialized = yield* current.commands.run( + initialization.realtime({ databaseUrl: targetUrl }), + ); + expect(initialized.exitCode).toBe(0); + expect((yield* current.services.list).map((service) => service.id)).toEqual( + existingServices.map((service) => service.id), + ); + } + const runSql = Effect.fn("ResetData.runSql")(( client: typeof current, url: string, @@ -228,7 +233,7 @@ const resetDataStory = (runtime: "native" | "docker") => ) => { const stdout: Array = []; const stderr: Array = []; - return client.tools + return client.commands .run(postgres.psql({ major: 17 }), { args: ["--dbname", url, "-Atc", sql], stdout: (bytes) => Effect.sync(() => stdout.push(new TextDecoder().decode(bytes))), diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 2c35b17b8c..d626dfc68b 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -46,7 +46,12 @@ import { type ServiceCreationInput as CatalogServiceCreationInput, } from "./services/Catalog.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { PgProveOptions, PostgresTool } from "./Tools.ts"; +import type { + InitializationCommand, + PgProveOptions, + PostgresCommand, + CommandInvocation, +} from "./Commands.ts"; export { DEFAULT_LOCAL_DATABASE_PASSWORD, DEFAULT_LOCAL_JWT_SECRET, @@ -62,7 +67,7 @@ export { DEFAULT_SIGNING_KEY, } from "./Defaults.ts"; -export { postgres } from "./Tools.ts"; +export { initialization, postgres } from "./Commands.ts"; export { resolveNativePostgresUser } from "./runtime/postgres-user.ts"; export { StackError } from "./Rpc.ts"; export type { ServiceCreation } from "./services/Catalog.ts"; @@ -71,7 +76,12 @@ export type { CompositionConfig } from "./Orchestrator.ts"; export type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; export type { StackCredentials, StackIdentityInput }; export type { Observation } from "./Rpc.ts"; -export type { PgProveOptions } from "./Tools.ts"; +export type { + Command, + InitializationCommand, + PgProveOptions, + PostgresCommand, +} from "./Commands.ts"; const stateFor = (root: string) => State.Service.pipe(Effect.provide(State.layer({ root }))); @@ -118,6 +128,7 @@ export interface ServiceInstance { readonly stop: Effect.Effect; readonly restart: (input?: ServiceCreationRestartInput) => Effect.Effect; readonly destroy: Effect.Effect; + /** Ensures the service artifact or image is available without starting the service. */ readonly prepare: Effect.Effect; readonly status: Effect.Effect; readonly followStatus: Stream.Stream; @@ -154,8 +165,8 @@ export type DestroyResult = readonly engine: "docker" | "podman"; readonly cleanupCommands: ReadonlyArray; }; -/** An attached finite command with backpressured byte streams. */ -export interface ToolOptions { +/** Options for streaming PostgreSQL command input and output. */ +export interface PostgresCommandOptions { readonly args?: ReadonlyArray; readonly env?: Readonly>; readonly pgProve?: PgProveOptions; @@ -163,6 +174,30 @@ export interface ToolOptions { readonly stdout: (bytes: Uint8Array) => Effect.Effect; readonly stderr: (bytes: Uint8Array) => Effect.Effect; } +/** Output handlers for a finite service initialization command. */ +export interface InitializationCommandOptions { + readonly stdout?: (bytes: Uint8Array) => Effect.Effect; + readonly stderr?: (bytes: Uint8Array) => Effect.Effect; +} +interface InternalCommandOptions { + readonly args?: ReadonlyArray; + readonly env?: Readonly>; + readonly pgProve?: PgProveOptions; + readonly stdin?: Stream.Stream; + readonly stdout?: (bytes: Uint8Array) => Effect.Effect; + readonly stderr?: (bytes: Uint8Array) => Effect.Effect; +} +/** Runs a finite PostgreSQL or service initialization command. */ +export interface CommandRunner { + ( + command: PostgresCommand, + options: PostgresCommandOptions, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + ( + command: InitializationCommand, + options?: InitializationCommandOptions, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; +} /** A client handle; its lifetime does not own service processes. */ export interface Stack { readonly id: string; @@ -189,11 +224,8 @@ export interface Stack { }; readonly stop: Effect.Effect; readonly destroy: Effect.Effect; - readonly tools: { - readonly run: ( - tool: PostgresTool, - options: ToolOptions, - ) => Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + readonly commands: { + readonly run: CommandRunner; }; } @@ -431,81 +463,117 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( function create(creation: ServiceCreationInput): Effect.Effect { return call("createService", (rpc) => rpc.createService(creation)).pipe(Effect.map(instance)); } - const run = Effect.fn("Stack.runTool")(function* ( - tool: PostgresTool, - options: ToolOptions, + const runInvocation = Effect.fn("Stack.runCommand")(function* ( + command: CommandInvocation, + stdin: Stream.Stream | undefined, + stdout: (bytes: Uint8Array) => Effect.Effect, + stderr: (bytes: Uint8Array) => Effect.Effect, ) { return yield* Effect.scoped( Effect.gen(function* () { - const rpc = yield* client(true).pipe(Effect.mapError((cause) => failure("tool", cause))); + const rpc = yield* client(true).pipe(Effect.mapError((cause) => failure("command", cause))); const attachmentId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => failure("tool", cause)), + Effect.mapError((cause) => failure("command", cause)), ); const scope = yield* Scope.Scope; const inputFailure = yield* Deferred.make(); const result = yield* Ref.make<{ jobId: string; exitCode: number } | undefined>(undefined); const sender = yield* Ref.make | undefined>(undefined); - yield* rpc - .runTool({ - attachmentId, - tool, - args: options.args ?? [], - env: options.env ?? {}, - ...(options.pgProve === undefined ? {} : { pgProve: options.pgProve }), - stdin: options.stdin !== undefined, - }) - .pipe( - Stream.mapError((cause) => failure("tool", cause)), - Stream.runForEach((event): Effect.Effect => - Match.valueTags(event, { - Attached: () => - options.stdin === undefined - ? Effect.void - : options.stdin.pipe( - Stream.runForEach((bytes) => - Effect.forEach( - Array.from({ length: Math.ceil(bytes.length / 65536) }, (_, index) => - bytes.subarray(index * 65536, (index + 1) * 65536), - ), - (chunk) => - rpc - .toolInput({ attachmentId, bytes: chunk }) - .pipe(Effect.mapError((cause) => failure("stdin", cause))), - { discard: true }, + const encodedCommand = + command.type === "postgres" && command.pgProve !== undefined + ? { + ...command, + pgProve: { + ...command.pgProve, + mounts: command.pgProve.mounts.map(({ source, target }) => ({ source, target })), + }, + } + : command; + yield* rpc.runCommand({ attachmentId, command: encodedCommand }).pipe( + Stream.mapError((cause) => failure("command", cause)), + Stream.runForEach((event): Effect.Effect => + Match.valueTags(event, { + Attached: () => + stdin === undefined + ? Effect.void + : stdin.pipe( + Stream.runForEach((bytes) => + Effect.forEach( + Array.from({ length: Math.ceil(bytes.length / 65536) }, (_, index) => + bytes.subarray(index * 65536, (index + 1) * 65536), ), + (chunk) => + rpc + .commandInput({ attachmentId, bytes: chunk }) + .pipe(Effect.mapError((cause) => failure("stdin", cause))), + { discard: true }, ), - Effect.andThen( - rpc - .toolInput({ attachmentId, bytes: null }) - .pipe(Effect.mapError((cause) => failure("stdin", cause))), - ), - Effect.catchIf( - (cause) => - Schema.is(StackErrorSchema)(cause) && - cause.operation === "tool-input-closed", - () => Effect.void, - ), - Effect.tapCause((cause) => Deferred.failCause(inputFailure, cause)), - Effect.forkIn(scope), - Effect.flatMap((fiber) => Ref.set(sender, fiber)), ), - Stdout: (output) => options.stdout(output.bytes), - Stderr: (output) => options.stderr(output.bytes), - Completed: (completed) => - Ref.set(result, { jobId: completed.jobId, exitCode: completed.exitCode }), - }), - ), - Effect.raceFirst(Deferred.await(inputFailure)), - ); + Effect.andThen( + rpc + .commandInput({ attachmentId, bytes: null }) + .pipe(Effect.mapError((cause) => failure("stdin", cause))), + ), + Effect.catchIf( + (cause) => + Schema.is(StackErrorSchema)(cause) && + cause.operation === "command-input-closed", + () => Effect.void, + ), + Effect.tapCause((cause) => Deferred.failCause(inputFailure, cause)), + Effect.forkIn(scope), + Effect.flatMap((fiber) => Ref.set(sender, fiber)), + ), + Stdout: (output) => stdout(output.bytes), + Stderr: (output) => stderr(output.bytes), + Completed: (completed) => + Ref.set(result, { jobId: completed.jobId, exitCode: completed.exitCode }), + }), + ), + Effect.raceFirst(Deferred.await(inputFailure)), + ); const input = yield* Ref.get(sender); if (input !== undefined) yield* Fiber.interrupt(input); const completed = yield* Ref.get(result); if (completed === undefined) - return yield* failure("tool", "Tool attachment ended without an exit result"); + return yield* failure("command", "Command attachment ended without an exit result"); return completed; }), ); }); + function run( + command: PostgresCommand, + options: PostgresCommandOptions, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function run( + command: InitializationCommand, + options?: InitializationCommandOptions, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function run( + command: PostgresCommand | InitializationCommand, + options?: InternalCommandOptions, + ) { + if ("type" in command) + return runInvocation( + command, + undefined, + options?.stdout ?? (() => Effect.void), + options?.stderr ?? (() => Effect.void), + ); + return runInvocation( + { + type: "postgres", + command, + args: options?.args ?? [], + env: options?.env ?? {}, + ...(options?.pgProve === undefined ? {} : { pgProve: options.pgProve }), + stdin: options?.stdin !== undefined, + }, + options?.stdin, + options?.stdout ?? (() => Effect.void), + options?.stderr ?? (() => Effect.void), + ); + } const savedDefinition = Effect.gen(function* () { const current = yield* state.read(saved.id); if (current === undefined) return yield* failure("definition", "Stack does not exist"); @@ -568,7 +636,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( }, stop: shutdown(false).pipe(Effect.asVoid), destroy: shutdown(true), - tools: { run }, + commands: { run }, } satisfies Stack; }); diff --git a/packages/stack/src/host/ToolAttachments.integration.test.ts b/packages/stack/src/host/CommandAttachments.integration.test.ts similarity index 79% rename from packages/stack/src/host/ToolAttachments.integration.test.ts rename to packages/stack/src/host/CommandAttachments.integration.test.ts index c0597659ee..7dd4a49536 100644 --- a/packages/stack/src/host/ToolAttachments.integration.test.ts +++ b/packages/stack/src/host/CommandAttachments.integration.test.ts @@ -1,17 +1,17 @@ import { expect, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Ref, Stream } from "effect"; import { StackError } from "../Rpc.ts"; -import { postgres } from "../Tools.ts"; -import { makeToolAttachments } from "./ToolAttachments.ts"; +import { postgres } from "../Commands.ts"; +import { makeCommandAttachments } from "./CommandAttachments.ts"; -it.live("stopAll waits for an admitted tool to register and interrupt its runner", () => +it.live("stopAll waits for an admitted command to register and interrupt its runner", () => Effect.scoped( Effect.gen(function* () { const admissionStarted = yield* Deferred.make(); const releaseAdmission = yield* Deferred.make(); const runnerStarted = yield* Deferred.make(); const runnerFinalized = yield* Deferred.make(); - const attachments = yield* makeToolAttachments({ + const attachments = yield* makeCommandAttachments({ admit: Deferred.succeed(admissionStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseAdmission)), ), @@ -30,10 +30,13 @@ it.live("stopAll waits for an admitted tool to register and interrupt its runner attachments .run({ attachmentId: "admitted", - tool: postgres.psql({ major: 17 }), - args: [], - env: {}, - stdin: false, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: [], + env: {}, + stdin: false, + }, }) .pipe(Stream.runDrain), ); diff --git a/packages/stack/src/host/ToolAttachments.ts b/packages/stack/src/host/CommandAttachments.ts similarity index 57% rename from packages/stack/src/host/ToolAttachments.ts rename to packages/stack/src/host/CommandAttachments.ts index 1e2b0bc24d..337984e941 100644 --- a/packages/stack/src/host/ToolAttachments.ts +++ b/packages/stack/src/host/CommandAttachments.ts @@ -1,45 +1,57 @@ import { Cause, Effect, Fiber, Queue, Ref, Schema, Semaphore, Stream } from "effect"; -import { StackError, ToolEvent as ToolEventSchema } from "../Rpc.ts"; -import type { PgProveOptions, PostgresTool } from "../Tools.ts"; -import type * as ToolRunner from "./ToolRunner.ts"; -import type { ToolInput } from "./ToolRunner.ts"; +import { StackError, CommandEvent as CommandEventSchema } from "../Rpc.ts"; +import type { CommandInvocation } from "../Commands.ts"; +import type * as CommandRunner from "./CommandRunner.ts"; -type ToolEvent = Schema.Schema.Type; +type CommandEvent = Schema.Schema.Type; -export interface ToolAttachmentPayload { +export interface CommandAttachmentPayload { readonly attachmentId: string; - readonly tool: PostgresTool; - readonly args: ReadonlyArray; - readonly env: Readonly>; - readonly pgProve?: PgProveOptions; - readonly stdin: boolean; + readonly command: CommandInvocation; +} + +interface AttachedCommandOutput { + readonly stdout: (bytes: Uint8Array) => Effect.Effect; + readonly stderr: (bytes: Uint8Array) => Effect.Effect; } +type AttachedCommandInput = + | (AttachedCommandOutput & { + readonly command: Extract; + readonly stdin: Stream.Stream | undefined; + }) + | (AttachedCommandOutput & { + readonly command: Exclude; + }); interface Attachment { readonly attachmentId: string; readonly stdin: boolean; readonly input: Queue.Queue; - readonly output: Queue.Queue; + readonly output: Queue.Queue; readonly fiber: Ref.Ref | undefined>; } -interface ToolAttachmentOperations { - readonly run: (input: ToolAttachmentPayload) => Stream.Stream; +interface CommandAttachmentOperations { + readonly run: (input: CommandAttachmentPayload) => Stream.Stream; readonly input: ( attachmentId: string, - stdin: boolean, bytes: Uint8Array | null, ) => Effect.Effect; readonly stopAll: Effect.Effect; } -export interface ToolAttachmentOptions { +export interface CommandAttachmentOptions { readonly admit: Effect.Effect; - readonly run: ToolRunner.Interface["run"]; + readonly run: ( + input: AttachedCommandInput, + ) => Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + CommandRunner.CommandError + >; readonly toError: (operation: string, cause: unknown) => StackError; } -export const makeToolAttachments = (options: ToolAttachmentOptions) => +export const makeCommandAttachments = (options: CommandAttachmentOptions) => Effect.gen(function* () { const entries = yield* Ref.make(new Map()); const admission = yield* Semaphore.make(1); @@ -50,15 +62,15 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => (entry) => Effect.gen(function* () { yield* Queue.shutdown(entry.input); - yield* Queue.fail(entry.output, options.toError("tool", "Stack host is draining")); + yield* Queue.fail(entry.output, options.toError("command", "Stack host is draining")); const runner = yield* Ref.get(entry.fiber); if (runner !== undefined) yield* Fiber.interrupt(runner); }), { discard: true }, ); - }).pipe(Effect.withSpan("ToolAttachments.stopAll")); + }).pipe(Effect.withSpan("CommandAttachments.stopAll")); - const run = (input: ToolAttachmentPayload): Stream.Stream => + const run = (input: CommandAttachmentPayload): Stream.Stream => Stream.unwrap( admission.withPermits(1)( Effect.gen(function* () { @@ -67,39 +79,39 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => Effect.map((values) => values.get(input.attachmentId)), ); if (existing !== undefined) - return yield* options.toError("tool", "Attachment is already in use"); + return yield* options.toError("command", "Attachment is already in use"); const attachment: Attachment = { attachmentId: input.attachmentId, - stdin: input.stdin, + stdin: input.command.type === "postgres" && input.command.stdin, input: yield* Queue.bounded(1), - output: yield* Queue.make({ capacity: 16 }), + output: yield* Queue.make({ capacity: 16 }), fiber: yield* Ref.make | undefined>(undefined), }; yield* Ref.update(entries, (values) => new Map(values).set(input.attachmentId, attachment), ); - const stdin = input.stdin + const stdin = attachment.stdin ? Stream.fromQueue(attachment.input).pipe( Stream.takeWhile((chunk): chunk is Uint8Array => chunk !== null), ) : Stream.empty; - const write = (event: ToolEvent) => + const write = (event: CommandEvent) => Queue.offer(attachment.output, event).pipe(Effect.asVoid); + const outputs = { + stdout: (bytes: Uint8Array) => write({ _tag: "Stdout", bytes }), + stderr: (bytes: Uint8Array) => write({ _tag: "Stderr", bytes }), + }; const runner = options - .run({ - tool: input.tool, - args: input.args, - env: input.env, - pgProve: input.pgProve, - stdin, - stdout: (bytes) => write({ _tag: "Stdout", bytes }), - stderr: (bytes) => write({ _tag: "Stderr", bytes }), - } satisfies ToolInput) + .run( + input.command.type === "postgres" + ? { command: input.command, stdin, ...outputs } + : { command: input.command, ...outputs }, + ) .pipe( Effect.flatMap((result) => write({ _tag: "Completed", jobId: result.jobId, exitCode: result.exitCode }), ), - Effect.mapError((cause) => options.toError("tool", cause)), + Effect.mapError((cause) => options.toError("command", cause)), Effect.tapError((cause) => Queue.fail(attachment.output, cause)), Effect.ensuring( Effect.gen(function* () { @@ -119,7 +131,7 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => Stream.succeed({ _tag: "Attached", attachmentId: input.attachmentId, - } satisfies ToolEvent), + } satisfies CommandEvent), Stream.fromQueue(attachment.output), ); return stream.pipe( @@ -131,28 +143,28 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => }), ), ); - }).pipe(Effect.withSpan("ToolAttachments.run")), + }).pipe(Effect.withSpan("CommandAttachments.run")), ), ); - const input = Effect.fn("ToolAttachments.input")( - (attachmentId: string, stdin: boolean, bytes: Uint8Array | null) => + const input = Effect.fn("CommandAttachments.input")( + (attachmentId: string, bytes: Uint8Array | null) => Ref.get(entries).pipe( Effect.flatMap((values) => { const attachment = values.get(attachmentId); if (attachment === undefined) - return Effect.fail(options.toError("tool-input-closed", "Attachment is closed")); - if (!stdin || !attachment.stdin) - return Effect.fail(options.toError("toolInput", "Tool did not request stdin")); + return Effect.fail(options.toError("command-input-closed", "Attachment is closed")); + if (!attachment.stdin) + return Effect.fail(options.toError("command-input", "Command did not request stdin")); return Queue.offer(attachment.input, bytes).pipe( Effect.flatMap((accepted) => accepted ? Effect.void - : Effect.fail(options.toError("tool-input-closed", "Attachment is closed")), + : Effect.fail(options.toError("command-input-closed", "Attachment is closed")), ), ); }), ), ); - return { run, input, stopAll } satisfies ToolAttachmentOperations; + return { run, input, stopAll } satisfies CommandAttachmentOperations; }); diff --git a/packages/stack/src/host/CommandRunner.initialization.integration.test.ts b/packages/stack/src/host/CommandRunner.initialization.integration.test.ts new file mode 100644 index 0000000000..28e1c4c48d --- /dev/null +++ b/packages/stack/src/host/CommandRunner.initialization.integration.test.ts @@ -0,0 +1,234 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Context, Deferred, Effect, Fiber, FileSystem, Layer, Path, Ref } from "effect"; +import { TestClock } from "effect/testing"; +import { initialization } from "../Commands.ts"; +import { + makeArtifactStore, + type ArtifactRequest, + type ArtifactSource, +} from "../preparation/ArtifactStore.ts"; +import { PreparationError } from "../preparation/Errors.ts"; +import type { StackCredentials } from "../State.ts"; +import * as CommandRunner from "./CommandRunner.ts"; + +const target = + process.platform === "darwin" && process.arch === "arm64" + ? "darwin-arm64" + : process.platform === "linux" && process.arch === "x64" + ? "linux-amd64" + : process.platform === "linux" && process.arch === "arm64" + ? "linux-arm64" + : undefined; + +const prepareAuthArtifact = Effect.fn(function* (cacheRoot: string, script: string) { + if (target === undefined) return yield* Effect.fail("Unsupported test platform"); + const fs = yield* FileSystem.FileSystem; + const request: ArtifactRequest = { + key: `slim-services/auth/v2.196.0/${target}`, + requiredRuntimePaths: ["bin/auth"], + executablePath: "bin/auth", + }; + const source: ArtifactSource = { + checksum: () => Effect.succeed("0".repeat(64)), + materialize: (_request, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/auth`, script); + yield* fs.chmod(`${destination}/bin/auth`, 0o755); + }).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: `Unable to write Auth command fixture: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot, source }); + yield* store.prepare(request); +}); + +const makeRunner = Effect.fn(function* (root: string, cacheRoot: string) { + const layer = CommandRunner.layer({ + stackId: "initialization-command-test", + root, + cacheRoot, + runtime: "native", + }).pipe(Layer.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))); + return Context.get(yield* Layer.build(layer), CommandRunner.Service); +}); + +const credentials: StackCredentials = { + jwtSecret: "fixture-stack-jwt-secret-with-more-than-32-characters", + postgresRootKey: "fixture-postgres-root-key", + databasePassword: "fixture-database-password", + publishableKey: "fixture-publishable-key", + secretKey: "fixture-secret-key", + anonKey: "fixture-anon-key", + serviceRoleKey: "fixture-service-role-key", + jwks: "fixture-jwks", + gotrueJwtKeys: "fixture-gotrue-jwt-keys", + remoteJwks: "fixture-remote-jwks", + anonKeyIsOverride: false, + serviceRoleKeyIsOverride: false, +}; + +it.live.skipIf(target === undefined || process.platform === "win32")( + "reports bounded stdout and stderr when an initialization command fails", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "initialization-command-failure-", + }); + const cacheRoot = path.join(root, "cache"); + yield* prepareAuthArtifact( + cacheRoot, + `#!${process.execPath}\nfor (let index = 0; index < 30; index++) { console.log("x".repeat(1500) + " stdout-tail-" + index); console.error("y".repeat(1500) + " stderr-tail-" + index); }\nconsole.error("jwt=" + process.env.GOTRUE_JWT_SECRET);\nconsole.error("keys=" + process.env.GOTRUE_JWT_KEYS);\nconsole.error("fixture migration failure");\nprocess.exit(7);\n`, + ); + const runner = yield* makeRunner(root, cacheRoot); + const error = yield* runner + .run({ + command: initialization.auth({ + version: "v2.196.0", + databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", + }), + credentials, + stdout: () => Effect.void, + stderr: () => Effect.void, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("auth.initialize exited with code 7"); + expect(error.message).toContain("stdout-tail-29"); + expect(error.message).not.toContain("stdout-tail-0"); + expect(error.message).toContain("stderr-tail-29"); + expect(error.message).not.toContain("stderr-tail-0"); + expect(error.message).toContain("fixture migration failure"); + expect(error.message).toContain(`jwt=${credentials.jwtSecret}`); + expect(error.message).toContain(`keys=${credentials.gotrueJwtKeys}`); + expect(error.message.length).toBeLessThan(45_000); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ), +); + +it.live.skipIf(target === undefined || process.platform === "win32")( + "interrupts a running initialization command and completes its cleanup", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "initialization-command-cancel-", + }); + const cacheRoot = path.join(root, "cache"); + yield* prepareAuthArtifact( + cacheRoot, + `#!${process.execPath}\nconsole.log("command started " + process.pid);\nsetInterval(() => {}, 1000);\n`, + ); + const runner = yield* makeRunner(root, cacheRoot); + const started = yield* Deferred.make(); + const pid = yield* Ref.make(undefined); + const command = yield* runner + .run({ + command: initialization.auth({ + version: "v2.196.0", + databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", + }), + credentials, + stdout: (bytes) => + Effect.gen(function* () { + const match = /command started (\d+)/.exec(new TextDecoder().decode(bytes)); + if (match?.[1] !== undefined) { + yield* Ref.set(pid, match[1]); + yield* Deferred.succeed(started, undefined); + } + }), + stderr: () => Effect.void, + }) + .pipe(Effect.forkScoped); + + yield* Deferred.await(started); + yield* Fiber.interrupt(command); + + const childPid = yield* Ref.get(pid); + if (childPid === undefined) + return yield* Effect.fail("Command process id was not observed"); + const childStillRunning = yield* Effect.sync(() => { + try { + process.kill(Number(childPid), 0); + return true; + } catch { + return false; + } + }); + const jobs = yield* fs.readDirectory(path.join(root, "jobs")); + expect(childStillRunning).toBe(false); + expect(jobs).toHaveLength(0); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ), +); + +it.effect.skipIf(target === undefined || process.platform === "win32")( + "reports bounded output when an initialization command times out", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "initialization-command-timeout-", + }); + const cacheRoot = path.join(root, "cache"); + yield* prepareAuthArtifact( + cacheRoot, + `#!${process.execPath}\nconsole.error("migration still running " + process.pid);\nsetInterval(() => {}, 1000);\n`, + ); + const runner = yield* makeRunner(root, cacheRoot); + const started = yield* Deferred.make(); + const pid = yield* Ref.make(undefined); + const command = yield* runner + .run({ + command: initialization.auth({ + version: "v2.196.0", + databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", + }), + credentials, + stdout: () => Effect.void, + stderr: (bytes) => + Effect.gen(function* () { + const match = /migration still running (\d+)/.exec(new TextDecoder().decode(bytes)); + if (match?.[1] !== undefined) { + yield* Ref.set(pid, match[1]); + yield* Deferred.succeed(started, undefined); + } + }), + }) + .pipe(Effect.forkScoped); + + yield* Deferred.await(started); + yield* TestClock.adjust("60 seconds"); + const error = yield* Fiber.join(command).pipe(Effect.flip); + + expect(error.message).toContain("auth.initialize timed out after 60 seconds"); + expect(error.message).toContain("migration still running"); + const childPid = yield* Ref.get(pid); + if (childPid === undefined) + return yield* Effect.fail("Command process id was not observed"); + const childStillRunning = yield* Effect.sync(() => { + try { + process.kill(Number(childPid), 0); + return true; + } catch { + return false; + } + }); + expect(childStillRunning).toBe(false); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ), +); diff --git a/packages/stack/src/host/ToolRunner.native-cleanup.integration.test.ts b/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts similarity index 84% rename from packages/stack/src/host/ToolRunner.native-cleanup.integration.test.ts rename to packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts index f5dd5653c4..240d72a977 100644 --- a/packages/stack/src/host/ToolRunner.native-cleanup.integration.test.ts +++ b/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts @@ -1,10 +1,10 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Effect, FileSystem, Layer, Path } from "effect"; +import { Context, Effect, FileSystem, Layer, Path, Stream } from "effect"; import { systemError } from "effect/PlatformError"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { postgres } from "../Tools.ts"; -import * as ToolRunner from "./ToolRunner.ts"; +import { postgres } from "../Commands.ts"; +import * as CommandRunner from "./CommandRunner.ts"; it.live.skipIf(process.platform === "win32")( "retries failed native workload cleanup when the stack runner is cleaned up", @@ -52,18 +52,23 @@ it.live.skipIf(process.platform === "win32")( }); }), ); - const layer = ToolRunner.layer({ + const layer = CommandRunner.layer({ stackId: "native-cleanup-test", root, cacheRoot, runtime: "native", }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); - const runner = Context.get(yield* Layer.build(layer), ToolRunner.Service); + const runner = Context.get(yield* Layer.build(layer), CommandRunner.Service); const result = yield* runner .run({ - tool: postgres.psql({ major: 17 }), - args: ["--version"], - env: {}, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: ["--version"], + env: {}, + stdin: false, + }, + stdin: Stream.empty, stdout: () => Effect.void, stderr: () => Effect.void, }) diff --git a/packages/stack/src/host/ToolRunner.ts b/packages/stack/src/host/CommandRunner.ts similarity index 59% rename from packages/stack/src/host/ToolRunner.ts rename to packages/stack/src/host/CommandRunner.ts index a80c4e6d0a..a383c69067 100644 --- a/packages/stack/src/host/ToolRunner.ts +++ b/packages/stack/src/host/CommandRunner.ts @@ -7,7 +7,6 @@ import { FileSystem, Layer, Path, - Schema, Sink, Stream, Ref, @@ -24,42 +23,53 @@ import { } from "../Artifacts.ts"; import { makeContainerRuntime } from "../runtime/Container.ts"; import { spawnNativeProcess } from "../runtime/NativeProcess.ts"; -import { PostgresTool, type PgProveOptions } from "../Tools.ts"; +import { awaitCommandOutput, type CommandOutputResult } from "../runtime/CommandOutput.ts"; +import type { CommandInvocation as CommandInvocationType } from "../Commands.ts"; +import type { StackCredentials } from "../State.ts"; +import { resolveInitializationCommand } from "../services/Initialization.ts"; -class ToolError extends Data.TaggedError("ToolError")<{ +export class CommandError extends Data.TaggedError("CommandError")<{ readonly message: string; readonly cause?: unknown; }> {} -class StdinWriteError extends Data.TaggedError("StdinWriteError")<{ readonly cause: ToolError }> {} +class StdinWriteError extends Data.TaggedError("StdinWriteError")<{ + readonly cause: CommandError; +}> {} -export interface ToolInput { - readonly tool: PostgresTool; - readonly args: ReadonlyArray; - readonly env: Readonly>; - readonly pgProve?: PgProveOptions; - readonly stdin?: Stream.Stream; +interface CommandInputCommon { readonly stdout: (bytes: Uint8Array) => Effect.Effect; readonly stderr: (bytes: Uint8Array) => Effect.Effect; } +type CommandInput = + | (CommandInputCommon & { + readonly command: Extract; + readonly stdin: Stream.Stream | undefined; + }) + | (CommandInputCommon & { + readonly command: Exclude; + readonly credentials: StackCredentials; + }); export interface Interface { readonly run: ( - input: ToolInput, - ) => Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | ToolError, R>; - readonly cleanup: Effect.Effect; + input: CommandInput, + ) => Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | CommandError, R>; + readonly cleanup: Effect.Effect; } -export class Service extends Context.Service()("@supabase/stack/ToolRunner") {} +export class Service extends Context.Service()( + "@supabase/stack/CommandRunner", +) {} const failure = (cause: unknown) => - new ToolError({ + new CommandError({ message: cause instanceof Error ? cause.message : String(cause), cause, }); /** Creates an attached byte-stream runner whose invocation scope owns each finite process. */ -const makeToolRunner = (options: { +const makeCommandRunner = (options: { readonly stackId: string; readonly root: string; readonly cacheRoot: string; @@ -85,7 +95,7 @@ const makeToolRunner = (options: { .makeDirectory(jobsRoot, { recursive: true, mode: 0o700 }) .pipe(Effect.mapError(failure)); - const nativeCleanup = yield* Ref.make(new Map>()); + const nativeCleanup = yield* Ref.make(new Map>()); const cleanupNative = (jobId: string) => Ref.get(nativeCleanup).pipe( Effect.flatMap((entries) => { @@ -125,26 +135,38 @@ const makeToolRunner = (options: { ); }); - const run = Effect.fn("ToolRunner.run")(function* (input: ToolInput) { + const run = Effect.fn("CommandRunner.run")(function* (input: CommandInput) { yield* fs .makeDirectory(jobsRoot, { recursive: true, mode: 0o700 }) .pipe(Effect.mapError(failure)); const jobId = yield* crypto.randomUUIDv4.pipe(Effect.mapError(failure)); return yield* Effect.scoped( Effect.gen(function* () { - const tool = yield* Schema.decodeEffect(PostgresTool)(input.tool).pipe( - Effect.mapError(failure), - ); - if (tool.command !== "pg_prove" && input.pgProve !== undefined) - return yield* failure("pgProve options require the pg_prove tool"); - const version = postgresVersion(String(tool.major)); + const command = input.command; const directory = yield* fs .makeTempDirectoryScoped({ directory: jobsRoot, prefix: `${jobId}-` }) .pipe(Effect.mapError(failure)); + const initialization = + "credentials" in input + ? yield* resolveInitializationCommand(input.command, { + runtime: options.runtime, + credentials: input.credentials, + }).pipe(Effect.mapError(failure)) + : undefined; + const postgresCommand = "stdin" in input ? input.command : undefined; + const inputStream = "stdin" in input ? (input.stdin ?? Stream.empty) : Stream.empty; + if ( + postgresCommand !== undefined && + postgresCommand.command.command !== "pg_prove" && + postgresCommand.pgProve !== undefined + ) + return yield* failure("pgProve options require the pg_prove command"); + const version = + initialization?.version ?? postgresVersion(String(postgresCommand?.command.major)); const process = yield* Effect.gen(function* () { if (container === undefined) { const artifact = yield* prepareNativeArtifact( - { service: "database", version }, + { service: initialization?.service ?? "database", version }, options.cacheRoot, ).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -155,10 +177,17 @@ const makeToolRunner = (options: { ); const child = yield* spawnNativeProcess( { - executable: path.join(artifact.root, "bin", tool.command), - args: input.args, - env: input.env, - cwd: input.pgProve?.cwd ?? directory, + executable: path.join( + artifact.root, + "bin", + postgresCommand?.command.command ?? initialization?.nativeExecutable ?? "", + ), + args: postgresCommand?.args ?? initialization?.args ?? [], + env: postgresCommand?.env ?? initialization?.env ?? {}, + cwd: + postgresCommand?.pgProve?.cwd ?? + initialization?.cwd ?? + (initialization === undefined ? directory : artifact.root), stdin: "pipe", }, undefined, @@ -180,18 +209,25 @@ const makeToolRunner = (options: { cleanup: Effect.void, }; } - const artifact = yield* resolveArtifact({ service: "database", version }); - yield* container.prepare(artifact.image); + const image = + initialization?.image ?? + (yield* resolveArtifact({ service: "database", version })).image; + yield* container.prepare(image); const child = yield* container - .launchTool({ - image: artifact.image, + .launchCommand({ + image, stackId: options.stackId, instanceId: jobId, - env: input.env, - args: input.args, - entrypoint: tool.command, - workingDir: input.pgProve?.workingDir, - mounts: input.pgProve?.mounts.map((mount) => ({ ...mount, readOnly: true })), + env: postgresCommand?.env ?? initialization?.env ?? {}, + args: postgresCommand?.args ?? initialization?.args ?? [], + entrypoint: + postgresCommand?.command.command ?? initialization?.containerEntrypoint ?? "", + workingDir: postgresCommand?.pgProve?.workingDir ?? initialization?.workingDir, + mounts: + postgresCommand?.pgProve?.mounts.map((mount) => ({ + ...mount, + readOnly: true, + })) ?? initialization?.mounts, }) .pipe(Effect.catchTag("ContainerLaunchError", (error) => Effect.fail(error.failure))); return { @@ -202,12 +238,12 @@ const makeToolRunner = (options: { cleanup: child.stop.pipe(Effect.andThen(child.remove), Effect.mapError(failure)), }; }).pipe(Effect.mapError(failure)); - const [, , , exitCode] = yield* Effect.acquireUseRelease( + const [, result] = yield* Effect.acquireUseRelease( Effect.succeed(process), (process) => Effect.all( [ - (input.stdin ?? Stream.empty).pipe( + inputStream.pipe( Stream.run( process.stdin.pipe(Sink.mapError((cause) => new StdinWriteError({ cause }))), ), @@ -217,15 +253,40 @@ const makeToolRunner = (options: { ), Effect.raceFirst(process.exitCode.pipe(Effect.asVoid)), ), - process.stdout.pipe(Stream.runForEach(input.stdout)), - process.stderr.pipe(Stream.runForEach(input.stderr)), - process.exitCode, + command.type === "postgres" + ? Effect.all( + [ + process.stdout.pipe(Stream.runForEach(input.stdout)), + process.stderr.pipe(Stream.runForEach(input.stderr)), + process.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([, , exitCode]): CommandOutputResult => ({ + timedOut: false, + exitCode: Number(exitCode), + output: { stdout: [], stderr: [] }, + })), + ) + : awaitCommandOutput(process, { + timeout: "60 seconds", + onOutput: (stream, bytes) => + stream === "stdout" ? input.stdout(bytes) : input.stderr(bytes), + }), ], { concurrency: "unbounded" }, ), (process) => process.cleanup, ); - return { jobId, exitCode }; + if (result.timedOut) + return yield* failure( + `${command.type} timed out after 60 seconds\nstdout:\n${result.output.stdout.join("\n")}\nstderr:\n${result.output.stderr.join("\n")}`, + ); + if (command.type !== "postgres" && result.exitCode !== 0) + return yield* failure( + `${command.type} exited with code ${result.exitCode}\nstdout:\n${result.output.stdout.join("\n")}\nstderr:\n${result.output.stderr.join("\n")}`, + ); + return { jobId, exitCode: result.exitCode }; }), ).pipe( Effect.onExit((exit) => @@ -241,4 +302,4 @@ export const layer = (options: { readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; -}) => Layer.effect(Service, makeToolRunner(options).pipe(Effect.map(Service.of))); +}) => Layer.effect(Service, makeCommandRunner(options).pipe(Effect.map(Service.of))); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 0ecb803f34..476c049901 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -6,7 +6,7 @@ import { ServiceCreationInput as CreationSchema, type ServiceCreation as EffectCreation, } from "./services/Catalog.ts"; -import type { PostgresTool } from "./Tools.ts"; +import type { InitializationCommand, PostgresCommand } from "./Commands.ts"; export { DEFAULT_LOCAL_DATABASE_PASSWORD, @@ -24,7 +24,7 @@ export { } from "./Defaults.ts"; export type { StackCredentials, StackIdentityInput } from "./State.ts"; -export { postgres } from "./Tools.ts"; +export { initialization, postgres } from "./Commands.ts"; export { StackError } from "./Rpc.ts"; type DatabaseCreation = Extract; /** Plain service configuration accepted by non-Effect callers. */ @@ -56,7 +56,7 @@ const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp type Runtime = ReturnType; const makeRuntime = () => ManagedRuntime.make(clientLayer); type Kind = ServiceCreation["service"]; -/** Optional cancellation ends the caller's wait, or its attached tool job. */ +/** Optional cancellation ends the caller's wait, or its attached command job. */ export interface CallOptions { readonly signal?: AbortSignal; } @@ -73,6 +73,7 @@ export interface ServiceInstance { options?: CallOptions, ) => Promise; readonly destroy: (options?: CallOptions) => Promise; + /** Ensures the service artifact or image is available without starting the service. */ readonly prepare: (options?: CallOptions) => Promise; readonly status: (options?: CallOptions) => Promise; readonly followStatus: () => AsyncIterable; @@ -96,8 +97,8 @@ export type ServiceInstances = { [K in Kind]: K extends "database" ? DatabaseInstance : ServiceInstance; }; type AnyInstance = ServiceInstances[Kind]; -/** Streaming inputs and awaited output sinks for an attached finite tool. */ -export interface ToolOptions extends CallOptions { +/** Streaming inputs and awaited output sinks for a PostgreSQL command. */ +export interface PostgresCommandOptions extends CallOptions { readonly args?: ReadonlyArray; readonly env?: Readonly>; readonly pgProve?: StackEffect.PgProveOptions; @@ -105,6 +106,19 @@ export interface ToolOptions extends CallOptions { readonly stdout: (bytes: Uint8Array) => void | Promise; readonly stderr: (bytes: Uint8Array) => void | Promise; } +/** Output sinks for a finite service initialization command. */ +export interface InitializationCommandOptions extends CallOptions { + readonly stdout?: (bytes: Uint8Array) => void | Promise; + readonly stderr?: (bytes: Uint8Array) => void | Promise; +} +interface InternalCommandOptions extends CallOptions { + readonly args?: ReadonlyArray; + readonly env?: Readonly>; + readonly pgProve?: StackEffect.PgProveOptions; + readonly stdin?: AsyncIterable; + readonly stdout?: (bytes: Uint8Array) => void | Promise; + readonly stderr?: (bytes: Uint8Array) => void | Promise; +} const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { const run = (effect: Effect.Effect, options?: CallOptions) => @@ -257,7 +271,7 @@ const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { } const sinkError = (cause: unknown) => new StackError({ - operation: "tool-stream", + operation: "command-stream", message: cause instanceof Error ? cause.message : String(cause), }); return { @@ -297,31 +311,53 @@ const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { ).then(() => runtime.dispose()); return clientClosePromise; }, - tools: { - run: (tool: PostgresTool, options: ToolOptions) => - run( - handle.tools.run(tool, { - args: options.args, - env: options.env, - pgProve: options.pgProve, - ...(options.stdin === undefined - ? {} - : { stdin: Stream.fromAsyncIterable(options.stdin, sinkError) }), - stdout: (bytes) => - Effect.tryPromise({ - try: () => Promise.resolve(options.stdout(bytes)), - catch: sinkError, - }), - stderr: (bytes) => - Effect.tryPromise({ - try: () => Promise.resolve(options.stderr(bytes)), - catch: sinkError, - }), - }), - options, - ), - }, + commands: { run: runCommands }, }; + + function runCommands( + command: PostgresCommand, + options: PostgresCommandOptions, + ): Promise<{ + readonly jobId: string; + readonly exitCode: number; + }>; + function runCommands( + command: InitializationCommand, + options?: InitializationCommandOptions, + ): Promise<{ + readonly jobId: string; + readonly exitCode: number; + }>; + function runCommands( + command: PostgresCommand | InitializationCommand, + options?: InternalCommandOptions, + ) { + const output = (sink: InternalCommandOptions["stdout"]) => (bytes: Uint8Array) => + sink === undefined + ? Effect.void + : Effect.tryPromise({ try: () => Promise.resolve(sink(bytes)), catch: sinkError }); + if ("type" in command) + return run( + handle.commands.run(command, { + ...(options?.stdout === undefined ? {} : { stdout: output(options.stdout) }), + ...(options?.stderr === undefined ? {} : { stderr: output(options.stderr) }), + }), + options, + ); + return run( + handle.commands.run(command, { + args: options?.args, + env: options?.env, + pgProve: options?.pgProve, + ...(options?.stdin === undefined + ? {} + : { stdin: Stream.fromAsyncIterable(options.stdin, sinkError) }), + stdout: output(options?.stdout), + stderr: output(options?.stderr), + }), + options, + ); + } }; /** A Promise client whose close operation leaves the detached owner running. */ export type Stack = ReturnType; diff --git a/packages/stack/src/public.e2e.test.ts b/packages/stack/src/public.e2e.test.ts index 1c2c951f94..36a34655a9 100644 --- a/packages/stack/src/public.e2e.test.ts +++ b/packages/stack/src/public.e2e.test.ts @@ -4,7 +4,8 @@ import { Effect, FileSystem, Layer, Path, Redacted, Schema, Stream } from "effec import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; -import { open, postgres } from "./effect.ts"; +import { open } from "./effect.ts"; +import { postgres } from "./Commands.ts"; import * as PromiseStack from "./index.ts"; import { assertOwnerExited, captureOwnerPid } from "../tests/owner.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; @@ -110,7 +111,7 @@ it.live( expect((yield* mail.credentials()).url).toBe(credentials.url); const stdoutChunks: Array = []; - const tool = yield* stack.tools.run(postgres.psql({ major: 17 }), { + const tool = yield* stack.commands.run(postgres.psql({ major: 17 }), { args: ["--version"], stdout: (bytes) => Effect.sync(() => { @@ -145,14 +146,14 @@ it.live( path.join(pgProveRoot, "nested.sql"), "SELECT plan(1);\nSELECT pass('public pgProve');\nSELECT * FROM finish();\n", ); - const extension = yield* stack.tools.run(postgres.psql({ major: 17 }), { + const extension = yield* stack.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "-c", "CREATE EXTENSION IF NOT EXISTS pgtap"], stdout: () => Effect.void, stderr: () => Effect.void, }); expect(extension.exitCode).toBe(0); const pgProveOutput: Array = []; - const pgProve = yield* stack.tools.run(postgres.pgProve({ major: 17 }), { + const pgProve = yield* stack.commands.run(postgres.pgProve({ major: 17 }), { args: ["--dbname", databaseUrl, "--ext", ".sql", "main.sql", "--verbose"], pgProve: { mounts: [{ source: pgProveRoot, target: "/tests" }], @@ -206,7 +207,7 @@ it.live( const databaseUrl = urls.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); const promiseExtension = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "-c", "CREATE EXTENSION IF NOT EXISTS pgtap"], stdout: () => {}, stderr: () => {}, @@ -216,7 +217,7 @@ it.live( const promiseProveOutput: Array = []; const promiseProveError: Array = []; const promiseProve = yield* Effect.tryPromise(() => - client.tools.run(postgres.pgProve({ major: 17 }), { + client.commands.run(postgres.pgProve({ major: 17 }), { args: ["--dbname", databaseUrl, "--ext", ".sql", "main.sql", "--verbose"], pgProve: { mounts: [{ source: pgProveRoot, target: "/tests" }], @@ -240,7 +241,7 @@ it.live( ).toContain("public pgProve"); const sqlOutput: Array = []; const sql = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "-At"], stdin: Stream.toAsyncIterable( Stream.make(new TextEncoder().encode("SELECT 42;\n")), @@ -265,7 +266,7 @@ it.live( return yield* Effect.die("Snapshot source URL missing"); const sourceUrl = sourceCredentials.databaseUrl; const seed = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: [ "--dbname", sourceUrl, @@ -306,7 +307,7 @@ it.live( const restoredUrl = restoredCredentials.databaseUrl; const restoredOutput: Array = []; const restoredQuery = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", restoredUrl, "-Atc", "SELECT value FROM snapshot_rows"], stdout: (bytes) => { restoredOutput.push(bytes); diff --git a/packages/stack/src/runtime/CommandOutput.ts b/packages/stack/src/runtime/CommandOutput.ts new file mode 100644 index 0000000000..348a6d79a6 --- /dev/null +++ b/packages/stack/src/runtime/CommandOutput.ts @@ -0,0 +1,79 @@ +import { Duration, Effect, Option, Ref, Stream } from "effect"; + +export interface CommandOutputProcess { + readonly stdout: Stream.Stream; + readonly stderr: Stream.Stream; + readonly exitCode: Effect.Effect; +} + +export type CommandOutputResult = + | { + readonly timedOut: true; + readonly output: Readonly<{ stdout: ReadonlyArray; stderr: ReadonlyArray }>; + } + | { + readonly timedOut: false; + readonly exitCode: number; + readonly output: Readonly<{ stdout: ReadonlyArray; stderr: ReadonlyArray }>; + }; + +const maxTailLines = 20; +const maxLineChars = 1_000; + +const clipLine = (line: string) => + line.length > maxLineChars + ? `…${line.slice(-maxLineChars).replace(/^[\uDC00-\uDFFF]/, "")}` + : line; + +/** Captures bounded process output while streaming bytes to the owning observer. */ +export const awaitCommandOutput = ( + process: CommandOutputProcess, + options: { + readonly timeout: Duration.Input; + readonly onOutput?: ( + stream: "stdout" | "stderr", + bytes: Uint8Array, + ) => Effect.Effect; + }, +): Effect.Effect => + Effect.gen(function* () { + const collect = ( + source: Stream.Stream, + name: "stdout" | "stderr", + tail: Ref.Ref>, + ) => + Effect.gen(function* () { + const appendLines = (lines: ReadonlyArray) => + Ref.update(tail, (current) => + [...current, ...lines.filter((line) => line.trim().length > 0).map(clipLine)].slice( + -maxTailLines, + ), + ); + const partial = yield* Ref.make(""); + yield* source.pipe( + Stream.tap((bytes) => options.onOutput?.(name, bytes) ?? Effect.void), + Stream.decodeText, + Stream.runForEach((text) => + Ref.modify(partial, (rest): [ReadonlyArray, string] => { + const lines = `${rest}${text}`.split(/\r?\n/); + const next = lines.pop() ?? ""; + return [lines, next.slice(-(maxLineChars + 1))]; + }).pipe(Effect.flatMap(appendLines)), + ), + Effect.ensuring(Ref.get(partial).pipe(Effect.flatMap((rest) => appendLines([rest])))), + ); + }); + const stdout = yield* Ref.make>([]); + const stderr = yield* Ref.make>([]); + const completed = yield* Effect.all( + [ + collect(process.stdout, "stdout", stdout), + collect(process.stderr, "stderr", stderr), + process.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe(Effect.timeoutOption(options.timeout)); + const output = { stdout: yield* Ref.get(stdout), stderr: yield* Ref.get(stderr) }; + if (Option.isNone(completed)) return { timedOut: true, output }; + return { timedOut: false, exitCode: Number(completed.value[2]), output }; + }); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index bbf5447d26..8658a899d1 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -104,7 +104,7 @@ describe("container process adapter", () => { Effect.gen(function* () { const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); yield* runtime.prepare(image); - const process = yield* runtime.launchTool({ + const process = yield* runtime.launchCommand({ image, stackId: "e".repeat(64), instanceId: "tool-input", diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 83596fc23a..eb08ccad72 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -75,7 +75,7 @@ export interface ContainerRuntime { readonly launch: ( spec: ContainerSpec, ) => Effect.Effect; - readonly launchTool: ( + readonly launchCommand: ( spec: Omit, ) => Effect.Effect; } @@ -524,7 +524,7 @@ export const makeContainerRuntime = (options: { }), ); }); - return { prepare, prepareImage, launch, launchTool: (spec) => launch(spec, true) }; + return { prepare, prepareImage, launch, launchCommand: (spec) => launch(spec, true) }; }); export const removeStackContainers = Effect.fn("Container.removeStackContainers")( diff --git a/packages/stack/src/services/Analytics.ts b/packages/stack/src/services/Analytics.ts index 93273d6c30..b25f211468 100644 --- a/packages/stack/src/services/Analytics.ts +++ b/packages/stack/src/services/Analytics.ts @@ -49,5 +49,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ }), args: () => Effect.succeed(["start"]), mounts: () => Effect.succeed([]), - startup: [{ args: [], nativeExecutable: "prepare", skipInContainer: true }], + startupCommands: [{ args: [], nativeExecutable: "prepare", skipInContainer: true }], }); diff --git a/packages/stack/src/services/Auth.ts b/packages/stack/src/services/Auth.ts index 850f672649..e923a14403 100644 --- a/packages/stack/src/services/Auth.ts +++ b/packages/stack/src/services/Auth.ts @@ -3,7 +3,7 @@ import { ServiceError } from "../Service.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { DEFAULT_SIGNING_KEY } from "../Defaults.ts"; import { localJwtSecret } from "./ServiceConfig.ts"; -import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; import { Settings, settingsEnvironment } from "./AuthSettings.ts"; @@ -41,6 +41,12 @@ export const Creation = serviceCreation("auth", Config, Endpoints); export interface Creation extends Schema.Schema.Type {} +export const initializationCommand = { + args: ["migrate"], + nativeExecutable: "auth", + containerEntrypoint: "/usr/local/bin/auth", +} satisfies StartupCommand & { readonly containerEntrypoint: string }; + const smtpEnvironment = Effect.fn("Auth.smtpEnvironment")((value: string) => Effect.try({ try: () => { @@ -134,7 +140,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ }, args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [ - { args: ["migrate"], nativeExecutable: "auth", containerEntrypoint: "/usr/local/bin/auth" }, - ], + startupCommands: [initializationCommand], }); diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index 37484f2ea5..271ea47978 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -142,20 +142,22 @@ const serviceError = (operation: string, cause: unknown) => const widen = ( isCreation: (value: unknown) => value is C, definition: ServiceDefinition, -): ServiceDefinition => ({ - prepare: (candidate) => - isCreation(candidate) - ? (definition.prepare?.(candidate) ?? Effect.void) - : Effect.fail(serviceError("prepare", "Service kind cannot change during restart")), - launch: (context) => - isCreation(context.config) - ? definition.launch({ ...context, config: context.config }) - : Effect.fail(serviceError("launch", "Service kind cannot change during restart")), - removeData: (context) => - isCreation(context.config) - ? definition.removeData({ ...context, config: context.config }) - : Effect.fail(serviceError("destroy", "Service kind cannot change during restart")), -}); +): ServiceDefinition => { + return { + prepare: (candidate) => + isCreation(candidate) + ? (definition.prepare?.(candidate) ?? Effect.void) + : Effect.fail(serviceError("prepare", "Service kind cannot change during restart")), + launch: (context) => + isCreation(context.config) + ? definition.launch({ ...context, config: context.config }) + : Effect.fail(serviceError("launch", "Service kind cannot change during restart")), + removeData: (context) => + isCreation(context.config) + ? definition.removeData({ ...context, config: context.config }) + : Effect.fail(serviceError("destroy", "Service kind cannot change during restart")), + }; +}; const catalogRecipe = ( creation: C, diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index c19dba1913..4ebaa5e33e 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -610,7 +610,7 @@ export const makeDatabase = ( }); return yield* Effect.scoped( Effect.gen(function* () { - const child = yield* container.launchTool({ + const child = yield* container.launchCommand({ image: artifact.image, stackId: String(options.stackId), instanceId: options.instanceId, diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 466d93c2a0..9cd40cd4dd 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -1,7 +1,7 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, FileSystem, Layer, Ref, Schedule, Schema, Stream } from "effect"; -import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; +import { Cause, Effect, FileSystem, Layer, Ref, Schema, Stream } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { makeService } from "../Service.ts"; import { bundleServeMainTemplate } from "../../tests/serve-main-bundler.ts"; @@ -20,21 +20,6 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); -// CI occasionally drops the first connection to a fresh container with no response. The notice -// goes to stderr because vitest hides console output from passing tests. -const getFunction = (client: HttpClient.HttpClient, url: string) => - client.execute(HttpClientRequest.get(url)).pipe( - Effect.retry( - Schedule.recurs(1).pipe( - Schedule.setInputType(), - Schedule.while(({ input }) => input.reason._tag === "TransportError"), - Schedule.tap(({ input }) => - Effect.sync(() => process.stderr.write(`Retrying ${url} after ${input.message}\n`)), - ), - ), - ), - ); - const dockerInfo = Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -101,9 +86,8 @@ describe("service catalog", () => { yield* instance.start; yield* instance.ready; const endpoint = yield* recipe.endpoint("http"); - const response = yield* getFunction( - client, - "http://" + endpoint.host + ":" + endpoint.port + "/hello", + const response = yield* client.execute( + HttpClientRequest.get("http://" + endpoint.host + ":" + endpoint.port + "/hello"), ); expect(response.status).toBe(200); expect(yield* response.json).toEqual( @@ -316,7 +300,7 @@ for (const runtime of ["native", "docker"] as const) { ); const endpoint = yield* recipe.endpoint("http"); const base = `http://${endpoint.host}:${endpoint.port}`; - const response = yield* getFunction(client, `${base}/hello`); + const response = yield* client.execute(HttpClientRequest.get(`${base}/hello`)); const responseText = yield* response.text; expect(response.status, responseText).toBe(200); const body = yield* Schema.decodeEffect( diff --git a/packages/stack/src/services/Functions.ts b/packages/stack/src/services/Functions.ts index b8c190ac60..e0b470ae44 100644 --- a/packages/stack/src/services/Functions.ts +++ b/packages/stack/src/services/Functions.ts @@ -200,7 +200,7 @@ const makeSpec = ( : [{ source: override, target: "/__supabase_bootstrap", readOnly: true }]), ]; }), - startup: [], + startupCommands: [], prepare: (creation) => bootstrap.write({ content: creation.config.bootstrap }).pipe( Effect.flatMap((target) => Ref.set(functionsRoot, path.dirname(target))), diff --git a/packages/stack/src/services/Imgproxy.ts b/packages/stack/src/services/Imgproxy.ts index de0bd405e1..7c497b7760 100644 --- a/packages/stack/src/services/Imgproxy.ts +++ b/packages/stack/src/services/Imgproxy.ts @@ -34,5 +34,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ ? [] : [{ source: creation.config.filePath, target: "/mnt", readOnly: true }], ), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Initialization.ts b/packages/stack/src/services/Initialization.ts new file mode 100644 index 0000000000..7e67c439f5 --- /dev/null +++ b/packages/stack/src/services/Initialization.ts @@ -0,0 +1,116 @@ +import { Effect } from "effect"; +import { resolveArtifact } from "../Artifacts.ts"; +import type { InitializationCommand, ResolvedCommand } from "../Commands.ts"; +import { ServiceError } from "../Service.ts"; +import type { StackCredentials } from "../State.ts"; +import * as Auth from "./Auth.ts"; +import * as Realtime from "./Realtime.ts"; +import * as Storage from "./Storage.ts"; +import { + resolveStartupCommand, + startupEndpointsFor, + type ProcessRecipeSpec, + type StartupCommand, +} from "./ProcessRecipe.ts"; +import type { RecipeCreation, CatalogOptions } from "./Recipe.ts"; + +const resolve = >( + service: C["service"], + creation: C, + spec: ProcessRecipeSpec, + command: StartupCommand & { readonly containerEntrypoint: string }, + runtime: CatalogOptions["runtime"], +): Effect.Effect => + Effect.gen(function* () { + const artifact = yield* resolveArtifact({ service, version: creation.version }); + const startup = yield* resolveStartupCommand( + creation, + spec, + command, + startupEndpointsFor(creation, spec, { container: runtime !== "native" }), + { container: runtime !== "native" }, + ); + return { + service, + version: artifact.version, + image: artifact.image, + nativeExecutable: startup.executable, + containerEntrypoint: command.containerEntrypoint, + args: startup.args, + env: startup.env, + mounts: startup.mounts, + } satisfies ResolvedCommand; + }).pipe( + Effect.mapError((cause) => + cause instanceof ServiceError + ? cause + : new ServiceError({ + operation: "command", + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + +/** Resolves a typed one-shot service command from the service's ordinary recipe. */ +export const resolveInitializationCommand = ( + input: InitializationCommand, + options: { + readonly runtime: CatalogOptions["runtime"]; + readonly credentials: StackCredentials; + }, +): Effect.Effect => { + switch (input.type) { + case "auth.initialize": + return resolve( + "auth", + { + service: "auth", + ...(input.version === undefined ? {} : { version: input.version }), + config: { + databaseUrl: input.databaseUrl, + jwtSecret: options.credentials.jwtSecret, + gotrueJwtKeys: options.credentials.gotrueJwtKeys, + }, + }, + Auth.makeSpec(), + Auth.initializationCommand, + options.runtime, + ); + case "storage.initialize": + return resolve( + "storage", + { + service: "storage", + ...(input.version === undefined ? {} : { version: input.version }), + config: { + databaseUrl: input.databaseUrl, + filePath: input.filePath, + jwtSecret: options.credentials.jwtSecret, + jwks: options.credentials.jwks, + anonKey: options.credentials.anonKey, + serviceRoleKey: options.credentials.serviceRoleKey, + }, + }, + Storage.makeSpec(), + Storage.initializationCommand, + options.runtime, + ); + case "realtime.initialize": + return resolve( + "realtime", + { + service: "realtime", + ...(input.version === undefined ? {} : { version: input.version }), + config: { + databaseUrl: input.databaseUrl, + jwtSecret: options.credentials.jwtSecret, + jwks: options.credentials.jwks, + }, + }, + Realtime.makeSpec(), + Realtime.initializationCommand, + options.runtime, + ); + } +}; diff --git a/packages/stack/src/services/Mail.ts b/packages/stack/src/services/Mail.ts index cfa43810b7..a1b535ddfd 100644 --- a/packages/stack/src/services/Mail.ts +++ b/packages/stack/src/services/Mail.ts @@ -50,5 +50,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ }, args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Pgmeta.ts b/packages/stack/src/services/Pgmeta.ts index 87c95452a3..27060901bf 100644 --- a/packages/stack/src/services/Pgmeta.ts +++ b/packages/stack/src/services/Pgmeta.ts @@ -37,5 +37,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ }), args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Pooler.ts b/packages/stack/src/services/Pooler.ts index 53b224b279..092e1bd474 100644 --- a/packages/stack/src/services/Pooler.ts +++ b/packages/stack/src/services/Pooler.ts @@ -105,7 +105,7 @@ export const makeSpec = (): ProcessRecipeSpec => ({ containerPort: (creation, name, port) => name === "sql" && creation.config.poolMode === "session" ? 5432 : port, containerEntrypoint: () => "/usr/bin/tini", - startup: [ + startupCommands: [ { args: [], nativeExecutable: "prepare", diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index ff36a2fcef..03348ea9e4 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -74,7 +74,7 @@ const spec: ProcessRecipeSpec = { args: () => Effect.succeed([]), env: () => Effect.succeed({}), mounts: () => Effect.succeed([]), - startup: [{ args: [] }], + startupCommands: [{ args: [] }], }; const isPortOccupied = (port: number): Effect.Effect => @@ -135,7 +135,7 @@ describe("ProcessRecipe launch cleanup", () => { const container: ContainerRuntime = { prepare: () => Effect.void, prepareImage: (image) => Effect.succeed(image), - launchTool: () => + launchCommand: () => Effect.gen(function* () { const launch = yield* Ref.updateAndGet(startupLaunches, (value) => value + 1); if (launch === 1) { @@ -254,7 +254,7 @@ describe("ProcessRecipe launch cleanup", () => { }; const nativeSpec: ProcessRecipeSpec = { ...spec, - startup: [{ nativeExecutable: "postgrest", args: ["--version"] }], + startupCommands: [{ nativeExecutable: "postgrest", args: ["--version"] }], }; const dependencies = { fs, @@ -365,7 +365,7 @@ describe("ProcessRecipe launch cleanup", () => { } return { PORT: String(port) }; }), - startup: [ + startupCommands: [ { nativeExecutable: path.relative(path.join(artifact.root, "bin"), process.execPath), args: [ @@ -419,7 +419,7 @@ const startupContainer = (tool: { prepare: () => Effect.void, prepareImage: (image) => Effect.succeed(image), launch: () => Effect.die("the main process must not launch after a failed startup"), - launchTool: () => + launchCommand: () => Effect.succeed({ id: "startup-tool", ports: {}, @@ -597,6 +597,7 @@ describe("process recipe startup", () => { const error = yield* Effect.flip(realtime.start); + expect(error).toMatchObject({ operation: "launch" }); expect(error.message).toContain("realtime startup exited with 1"); expect(error.message).toContain(postgrexFailure); expect(error.message).toContain(poolTimeout); diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 2602ca60c3..4802810ecb 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -7,7 +7,6 @@ import { Effect, Exit, FileSystem, - Option, Path, PubSub, Ref, @@ -25,6 +24,7 @@ import { type ContainerProcess, type ContainerRuntime, } from "../runtime/Container.ts"; +import { awaitCommandOutput } from "../runtime/CommandOutput.ts"; import { defaultNativeProcessLauncher, type NativeProcess, @@ -47,7 +47,7 @@ interface RecipeMount { readonly readOnly: boolean; } -interface StartupProcess { +export interface StartupCommand { readonly args: ReadonlyArray; readonly nativeExecutable?: string; readonly containerEntrypoint?: string; @@ -81,7 +81,7 @@ export interface ProcessRecipeSpec Effect.Effect, ServiceError>; - readonly startup: ReadonlyArray; + readonly startupCommands: ReadonlyArray; readonly enabledPort?: (creation: C, name: string) => boolean; readonly containerPort?: (creation: C, name: string, port: number) => number; readonly containerEntrypoint?: (creation: C) => string | undefined; @@ -89,6 +89,53 @@ export interface ProcessRecipeSpec Effect.Effect; } +export interface ResolvedStartupCommand { + readonly args: ReadonlyArray; + readonly executable: string; + readonly entrypoint?: string; + readonly env: Readonly>; + readonly mounts: ReadonlyArray; +} + +export const startupEndpointsFor = >( + creation: C, + spec: ProcessRecipeSpec, + context: { readonly container: boolean }, +): ReadonlyMap => + new Map( + Object.entries(spec.ports) + .filter(([name]) => spec.enabledPort === undefined || spec.enabledPort(creation, name)) + .map(([name, port]) => [ + name, + { + kind: "tcp" as const, + host: "127.0.0.1" as const, + port: context.container ? (spec.containerPort?.(creation, name, port) ?? port) : 0, + }, + ]), + ); + +export const resolveStartupCommand = >( + creation: C, + spec: ProcessRecipeSpec, + command: StartupCommand, + endpoints: ReadonlyMap, + context: { readonly container: boolean }, +): Effect.Effect => + Effect.gen(function* () { + return { + args: command.args, + executable: command.nativeExecutable ?? "prepare", + ...(command.containerEntrypoint === undefined + ? {} + : { entrypoint: command.containerEntrypoint }), + env: yield* context.container + ? spec.env(creation, endpoints, true) + : (spec.nativeStartupEnv ?? spec.env)(creation, endpoints, false), + mounts: yield* spec.mounts(creation, { container: context.container }), + }; + }); + export interface ProcessDependencies { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; @@ -247,57 +294,23 @@ const awaitStartup = Effect.fn("ProcessRecipe.awaitStartup")( Readonly<{ readonly code: number; readonly output: StartupOutput }>, ServiceError > => - Effect.gen(function* () { - const collect = Effect.fnUntraced(function* ( - stream: Stream.Stream, - name: CatalogLog["stream"], - tail: Ref.Ref>, - ) { - const appendLines = (lines: ReadonlyArray) => - Ref.update(tail, (current) => - [...current, ...lines.filter((line) => line.trim().length > 0).map(clipLine)].slice( - -startupOutputTailLines, - ), - ); - const partial = yield* Ref.make(""); - // The unterminated last line is flushed on interruption so a timeout still reports it. - yield* stream.pipe( - Stream.tap((bytes) => PubSub.publish(logs, { stream: name, bytes })), - Stream.decodeText, - Stream.runForEach((text) => - Ref.modify(partial, (rest): [ReadonlyArray, string] => { - const lines = `${rest}${text}`.split(/\r?\n/); - const next = lines.pop() ?? ""; - return [lines, next.slice(-(startupOutputLineChars + 1))]; - }).pipe(Effect.flatMap(appendLines)), - ), - Effect.ensuring(Ref.get(partial).pipe(Effect.flatMap((rest) => appendLines([rest])))), - ); - }); - const stdout = yield* Ref.make>([]); - const stderr = yield* Ref.make>([]); - const completed = yield* Effect.all( - [ - collect(process.stdout, "stdout", stdout), - collect(process.stderr, "stderr", stderr), - process.exitCode, - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => serviceError("launch", cause)), - Effect.timeoutOption(Duration.seconds(startupTimeoutSeconds)), - ); - const output = { stdout: yield* Ref.get(stdout), stderr: yield* Ref.get(stderr) }; - if (Option.isNone(completed)) - return yield* serviceError( - "launch", - withRecentOutput( - `${service} startup timed out after ${startupTimeoutSeconds} seconds`, - output, - ), - ); - return { code: Number(completed.value[2]), output }; - }), + awaitCommandOutput(process, { + timeout: Duration.seconds(startupTimeoutSeconds), + onOutput: (stream, bytes) => PubSub.publish(logs, { stream, bytes }), + }).pipe( + Effect.mapError((cause) => serviceError("launch", cause)), + Effect.flatMap((result) => + result.timedOut + ? serviceError( + "launch", + withRecentOutput( + `${service} startup timed out after ${startupTimeoutSeconds} seconds`, + result.output, + ), + ) + : Effect.succeed({ code: result.exitCode, output: result.output }), + ), + ), ); const startupFailure = ( @@ -506,7 +519,7 @@ export const makeProcessRecipe = readonly endpoints: ReadonlyMap; } | undefined; - if (spec.startup.length > 0) { + if (spec.startupCommands.length > 0) { const startupScope = yield* Scope.fork(context.scope, "sequential"); const reservation = spec.nativeStartupEnv === undefined @@ -514,22 +527,20 @@ export const makeProcessRecipe = : yield* reserveEndpoints(context.scope); const startupEndpoints = reservation?.endpoints ?? - new Map( - portNames.map(([name]) => [ - name, - { kind: "tcp" as const, host: "127.0.0.1", port: 0 }, - ]), + startupEndpointsFor(context.config, spec, { container: false }); + for (const [index, process] of spec.startupCommands.entries()) { + const command = yield* resolveStartupCommand( + context.config, + spec, + process, + startupEndpoints, + { container: false }, ); - for (const [index, process] of spec.startup.entries()) { const startupProcess = yield* spawnNativeProcess( { - executable: `${artifactRoot}/bin/${process.nativeExecutable ?? "prepare"}`, - args: process.args, - env: yield* (spec.nativeStartupEnv ?? spec.env)( - context.config, - startupEndpoints, - false, - ), + executable: `${artifactRoot}/bin/${command.executable}`, + args: command.args, + env: command.env, cwd: artifactRoot, }, defaultNativeProcessLauncher(), @@ -736,34 +747,31 @@ export const makeProcessRecipe = } return yield* launchAttempt(); } - const desired = new Map(); - for (const [name, port] of portNames) - desired.set(name, { kind: "tcp", host: "127.0.0.1", port }); if (deps.container === undefined) return yield* serviceError("launch", "Container runtime unavailable"); const resolved = yield* resolveArtifact({ service: context.config.service, version: context.config.version, }).pipe(Effect.mapError((cause) => serviceError("launch", cause))); - const containerDesired = new Map(); - for (const [name, endpoint] of desired) { - const port = - spec.containerPort === undefined - ? endpoint.port - : spec.containerPort(context.config, name, endpoint.port); - containerDesired.set(name, { ...endpoint, port }); - } - for (const process of spec.startup) { + const containerDesired = startupEndpointsFor(context.config, spec, { container: true }); + for (const process of spec.startupCommands) { if (process.skipInContainer === true) continue; + const command = yield* resolveStartupCommand( + context.config, + spec, + process, + containerDesired, + { container: true }, + ); const startupProcess = yield* deps.container - .launchTool({ + .launchCommand({ image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, - env: yield* spec.env(context.config, containerDesired, true), - entrypoint: process.containerEntrypoint, - args: process.args, - mounts: yield* spec.mounts(context.config, { container: true }), + env: command.env, + entrypoint: command.entrypoint, + args: command.args, + mounts: command.mounts, }) .pipe( Effect.catchTag("ContainerLaunchError", ({ failure, process }) => diff --git a/packages/stack/src/services/Realtime.ts b/packages/stack/src/services/Realtime.ts index f5744e39ed..e29af7f9ff 100644 --- a/packages/stack/src/services/Realtime.ts +++ b/packages/stack/src/services/Realtime.ts @@ -5,7 +5,7 @@ import { DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, DEFAULT_REALTIME_DB_ENCRYPTION_KEY, } from "../Defaults.ts"; -import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ databaseUrl: Schema.String, @@ -28,6 +28,11 @@ export const Creation = serviceCreation("realtime", Config, Endpoints); export interface Creation extends Schema.Schema.Type {} +export const initializationCommand = { + args: [], + containerEntrypoint: "/app/bin/prepare", +} satisfies StartupCommand & { readonly containerEntrypoint: string }; + export const makeSpec = (): ProcessRecipeSpec => ({ service: "realtime", executable: "bin/server", @@ -76,6 +81,6 @@ export const makeSpec = (): ProcessRecipeSpec => ({ args: (_creation, _endpoints, context) => Effect.succeed(context.container ? ["-s", "-g", "--", "/app/bin/server"] : []), mounts: () => Effect.succeed([]), - startup: [{ args: [], containerEntrypoint: "/app/bin/prepare" }], + startupCommands: [initializationCommand], containerEntrypoint: () => "/usr/bin/tini", }); diff --git a/packages/stack/src/services/Rest.ts b/packages/stack/src/services/Rest.ts index e238e6a039..48935da554 100644 --- a/packages/stack/src/services/Rest.ts +++ b/packages/stack/src/services/Rest.ts @@ -47,5 +47,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ }, args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Storage.ts b/packages/stack/src/services/Storage.ts index de97446864..ba696c0496 100644 --- a/packages/stack/src/services/Storage.ts +++ b/packages/stack/src/services/Storage.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { databaseConnection, localJwtSecret, serviceJwt } from "./ServiceConfig.ts"; -import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ databaseUrl: Schema.String, @@ -27,6 +27,11 @@ export const Creation = serviceCreation("storage", Config, Endpoints); export interface Creation extends Schema.Schema.Type {} +export const initializationCommand = { + args: [], + containerEntrypoint: "/slim-runtime/bin/prepare", +} satisfies StartupCommand & { readonly containerEntrypoint: string }; + export const makeSpec = (): ProcessRecipeSpec => ({ service: "storage", executable: "bin/storage", @@ -92,5 +97,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ args: () => Effect.succeed([]), mounts: (creation, _context) => Effect.succeed([{ source: creation.config.filePath, target: "/mnt", readOnly: false }]), - startup: [{ args: [], containerEntrypoint: "/slim-runtime/bin/prepare" }], + startupCommands: [initializationCommand], }); diff --git a/packages/stack/src/services/Studio.ts b/packages/stack/src/services/Studio.ts index 2bc03a70e8..f3818fb58b 100644 --- a/packages/stack/src/services/Studio.ts +++ b/packages/stack/src/services/Studio.ts @@ -85,5 +85,5 @@ export const makeSpec = (): ProcessRecipeSpec => ({ }, ], ), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Vector.ts b/packages/stack/src/services/Vector.ts index 3718e91d3b..083423b3c3 100644 --- a/packages/stack/src/services/Vector.ts +++ b/packages/stack/src/services/Vector.ts @@ -105,7 +105,7 @@ const makeSpec = ( { source: pipelinePath(creation), target: containerPipelinePath, readOnly: true }, { source: apiConfigPath, target: containerApiPath, readOnly: true }, ]), - startup: [], + startupCommands: [], prepare: (creation) => Effect.gen(function* () { yield* ownedInstance("prepare"); diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 798cb5e3cc..44e2a39e0d 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -179,7 +179,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { prepare: () => Effect.void, prepareImage: (image) => Effect.succeed(image), launch: () => Effect.die("unused"), - launchTool: () => Effect.die("unused"), + launchCommand: () => Effect.die("unused"), }, }); yield* storage.prepare("17"); diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index ce891afb98..f0a4078806 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -11,7 +11,7 @@ import { Ref, Stream, } from "effect"; -import { postgres } from "../../src/Tools.ts"; +import { postgres } from "../../src/Commands.ts"; import { homedir, tmpdir } from "node:os"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { bundleServeMainTemplate } from "../serve-main-bundler.ts"; @@ -338,7 +338,7 @@ export const sql = Effect.fn("WholeStack.sql")((fixture: WholeStack, statement: if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); const output: Array = []; const errors: Array = []; - const result = yield* fixture.stack.tools.run(postgres.psql({ major: 17 }), { + const result = yield* fixture.stack.commands.run(postgres.psql({ major: 17 }), { args: ["--set", "ON_ERROR_STOP=1", "--dbname", databaseUrl, "-At"], stdin: Stream.make(new TextEncoder().encode(`${statement}\n`)), stdout: (bytes) => Effect.sync(() => output.push(bytes)), From 2c006dd11a22faf6530054da8885e08e5ce30a6d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 10:43:13 +0000 Subject: [PATCH 10/71] fix(stack): harden readiness, port claims, and container storage (#6835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Several failure modes in the local runtime left stacks stuck or unreachable: - Health was checked once per launch and the failure was cached. One slow first boot left a running service that returned 502 until it was restarted. - Native services reserved a port by binding and releasing it, then launched on it. Only Pooler recovered from the resulting collision. - Every saved stack's port claims blocked other stacks, running or not. Switching git branch, or opening a second worktree, failed on the classic fixed ports until the other stack was destroyed. - One unreadable `state.json` in the state root blocked port allocation and owner startup for every stack. - `docker create` had no timeout and held the service's lifecycle gate while it ran. ## Change - Readiness is re-checked on demand. Callers share one bounded probe per launch, each session declares its own probe, and a stale probe can never mark a newer launch healthy. - A native launch that exits before becoming ready is relaunched on fresh ports, but only when its port is actually taken by another process. - Saved claims only steer automatic allocation; for fixed ports, the bind decides. A pre-bind probe runs where the OS lets loopback and wildcard binds overlap. - State listings skip unreadable entries and report them. `stop --all` warns about skipped entries. - A stack member counts as started only when it is running and healthy. - `docker create` is bounded, and the named container is removed on timeout or interrupt. - Unmarked database data is rejected at startup, and destroy still removes it. ## Stack Part 2 of 8 of the stack package simplification, based on #6834. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage ← this PR 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 10 +- apps/cli/docs/supabase-home.md | 8 +- .../experimental/stack/list/SIDE_EFFECTS.md | 7 +- .../stack/list/list.integration.test.ts | 4 +- .../experimental/stack/start/SIDE_EFFECTS.md | 12 +- .../experimental/stack/start/start.handler.ts | 98 ++++-- .../stack/start/start.integration.test.ts | 171 +++++++++- .../start/start.native.integration.test.ts | 7 +- .../experimental/stack/stop/SIDE_EFFECTS.md | 4 +- .../experimental/stack/stop/stop.handler.ts | 8 +- .../stack/stop/stop.integration.test.ts | 25 ++ packages/stack/README.md | 4 +- .../stack/src/Commands.integration.test.ts | 4 +- .../src/Orchestrator.integration.test.ts | 66 ++++ packages/stack/src/Ports.integration.test.ts | 257 ++++++++++++++- packages/stack/src/Ports.ts | 309 ++++++++++++------ .../stack/src/Service.integration.test.ts | 243 +++++++++++++- packages/stack/src/Service.ts | 186 +++++++---- packages/stack/src/State.integration.test.ts | 151 +++++++++ packages/stack/src/State.ts | 106 +++--- packages/stack/src/effect.integration.test.ts | 36 ++ packages/stack/src/effect.ts | 2 +- packages/stack/src/index.ts | 19 +- .../src/runtime/Container.integration.test.ts | 81 +++++ packages/stack/src/runtime/Container.ts | 47 ++- .../src/runtime/PostgresDatabaseSession.ts | 19 +- ...tgres-database-session.integration.test.ts | 66 ++++ packages/stack/src/services/Database.ts | 73 +++-- .../ProcessRecipe.integration.test.ts | 303 ++++++++++++++--- packages/stack/src/services/ProcessRecipe.ts | 286 ++++++++-------- .../DockerDatabaseStorage.integration.test.ts | 87 +++-- .../src/storage/DockerDatabaseStorage.ts | 130 ++++---- packages/stack/tests/docker-fixture.ts | 3 +- 33 files changed, 2235 insertions(+), 597 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 4ce21f7632..da9894b3d2 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -57,8 +57,9 @@ The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pre `-o env` becomes `--env`. `supabase stack list` reads the global managed-stack registry and reports each readable stack's -project, branch, runtime, and owner availability. A corrupt or unsupported registry entry fails the -whole discovery operation with a diagnostic; readable entries are not emitted as a partial list. +project, branch, runtime, and owner availability. Registry entries that cannot be read or decoded +are skipped with a warning on stderr identifying each stack; only a failure to read the stacks +directory itself fails discovery. The text table shortens readable IDs for scanning; use `--output-format json` or `--output-format stream-json` for the complete structured inventory with full IDs. @@ -229,8 +230,9 @@ Excluding `rest` while Studio remains selected is rejected; excluding `analytics Studio. The effective configuration is retained in stack state, so starting without `--exclude` restores the project's configured services. -`supabase stack stop --all` stops every managed stack while preserving data. Discovery fails closed -when any registry entry is unreadable, so no partial stop operation is attempted. Individual stop +`supabase stack stop --all` stops every managed stack while preserving data. Registry entries that +cannot be read or decoded are skipped with a warning on stderr; only a failure to read the stacks +directory itself fails discovery, before any stop is attempted. Individual stop failures make the command fail and identify the affected stack IDs with their error details; no success or unavailable summary is emitted when a stop fails. diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index 4f04baf5f3..b40e2c7217 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -89,9 +89,11 @@ no separate command that rewrites it, and no second project-level pinned-version Raw `supabase/config.toml` values and their origins are loaded before defaults are applied. Explicit sticky values are persisted as `exact` intents in each managed document. Omitted values remain -`automatic`; sibling worktrees have independent stack identities and allocations, while live exact -port conflicts are rejected by the manager. Runtime-only service ports are selected by the managed -supervisor and are not written to the document. +`automatic`; sibling worktrees and branches have independent stack identities and allocations. +Automatic allocation avoids ports saved by any stack, so stopped stacks keep their URLs. An exact +port is rejected only when a listener already answers on it or the port cannot be bound; another +stack's saved claim alone never blocks it, and a conflict names the stack that saved the port. Runtime-only service ports are +selected by the managed supervisor and are not written to the document. ## Command behavior diff --git a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md index 547285590d..d4ada831f3 100644 --- a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -23,10 +23,9 @@ registry prints `No managed stacks found.` Use `--output-format json` to obtain the full ID required by `--stack-id`; the text column shows only a prefix. JSON emits `{ "stacks": [...], "message": "" }`; stream-json wraps that data in -one result event. Invalid state documents are skipped with a warning on stderr identifying each stack. -Other registry read failures still fail discovery. State is never repaired or deleted; -opening stacks and allocating ports still reject invalid documents. Target resolution for -other stack commands also remains strict. +one result event. State entries that cannot be read or decoded are skipped with a warning on +stderr identifying each stack; only a failure to read the stacks directory itself fails +discovery. State is never repaired or deleted. ## Flags and exit codes diff --git a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts index 40d0f41840..36e1c08a0c 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -102,8 +102,8 @@ describe("stack list", () => { } expect(yield* fixture.fs.readFileString(file)).toBe("{broken"); expect(yield* fixture.fs.readFileString(mismatchFile)).toBe(healthyState); - const discoveryError = yield* fixture.api.discover(fixture.locations).pipe(Effect.flip); - expect(discoveryError.operation).toBe("discover"); + const discovered = yield* fixture.api.discover(fixture.locations); + expect(discovered.map(({ definition }) => definition.id)).toEqual([healthy.id]); const openError = yield* fixture.api .open({ ...fixture.locations, id: broken.id }) .pipe(Effect.flip); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 88b2dea447..db87ef1bc6 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -8,12 +8,16 @@ It creates or resumes the stack for the project and optional `--stack` name, or For a new stack or a stopped existing stack, the CLI loads the target project's `supabase/config.toml`, supported environment overrides, and project dotenv files. It validates the -supported configuration before creating service definitions. When the existing composition is -already running, start reports its current endpoints and returns without reading or applying project +supported configuration before creating service definitions. When every member of the existing +composition is running and healthy, or armed to wake and not already starting, start reports its +current endpoints and returns without reading or applying project configuration. When the database +is running and every other member is running with any health, starting, or armed to wake, start +notes that configuration changes apply after stop and start, starts the saved composition, and waits +until every member that was running or starting is ready, again without reading project configuration. -If the stack is in a partial lifecycle state, start fails with guidance to stop the stack and start -it again before applying configuration. +If the stack is otherwise in a partial lifecycle state, start fails with guidance to stop the stack +and start it again before applying configuration. Auth policies, OAuth providers, hooks, MFA, SMTP, email subjects and notification controls are forwarded to Auth. REST search paths, pooler limits, Realtime settings, Studio settings, Storage S3 protocol/vector controls, and configured Vector ports are forwarded to their services. diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 812dcedda4..0406fde085 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -4,6 +4,7 @@ import { defaultStackRuntime } from "../../../../command-internal/stack-runtime. import { Effect, Equal, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; import { resolveNativePostgresUser, + type Observation, type ServiceCreationInput, type Stack, type StackError, @@ -226,6 +227,26 @@ const compose = ( ...(reuseIds.length === 0 ? {} : { reuseIds }), }); +const isServing = (status: Pick) => + status.lifecycle === "running" && status.health === "healthy"; + +const reportEndpoints = ( + members: ReadonlyArray<{ + readonly service: string; + readonly status: Effect.Effect; + }>, +) => + Effect.forEach(members, (member) => + member.status.pipe( + Effect.mapError(stackError), + Effect.map((observation) => + Object.entries(endpointReports(observation)).map( + ([name, endpoint]) => [`${member.service}.${name}`, endpoint] as const, + ), + ), + ), + ).pipe(Effect.map((entries) => Object.fromEntries(entries.flat()))); + /** Starts the selected managed stack and applies the local database overlays. */ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags: StackStartFlags) { const telemetryState = yield* TelemetryState; @@ -308,26 +329,63 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const primaryDatabase = currentInstances.find((instance) => instance.service === "database"); const databaseStatus = currentStatuses.find(({ id }) => id === primaryDatabase?.id); const fullyStarted = - databaseStatus?.lifecycle === "running" && - currentStatuses.every(({ lifecycle, wakeEnabled }) => lifecycle === "running" || wakeEnabled); + databaseStatus !== undefined && + isServing(databaseStatus) && + currentStatuses.every((status) => + status.lifecycle === "running" + ? isServing(status) + : status.lifecycle !== "starting" && status.wakeEnabled, + ); if (fullyStarted) { yield* Ref.set(startupComplete, true); - const endpoints = Object.fromEntries( - currentStatuses.flatMap((observation, index) => { - const instance = currentInstances[index]; - return instance === undefined - ? [] - : Object.entries(endpointReports(observation)).map( - ([name, endpoint]) => [`${instance.service}.${name}`, endpoint] as const, - ); - }), - ); yield* output.success( "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", - { id: stack.id, endpoints }, + { id: stack.id, endpoints: yield* reportEndpoints(currentInstances) }, ); return stack.id; } + const resumable = + primaryDatabase !== undefined && + databaseStatus?.lifecycle === "running" && + currentStatuses.every( + ({ lifecycle, wakeEnabled }) => + lifecycle === "running" || lifecycle === "starting" || wakeEnabled, + ); + if (resumable) { + yield* output.info( + "Resuming the saved stack services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + ); + const starting = yield* output.task("Starting local Supabase stack..."); + yield* primaryDatabase.ready.pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError(stackError), + ); + yield* stack.composition.start.pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError((error) => stackError(error, currentInstances)), + ); + // Composition start awaits only eager members; lazy members that are up must be ready too. + const active = new Set( + currentStatuses + .filter(({ lifecycle }) => lifecycle === "running" || lifecycle === "starting") + .map(({ id }) => id), + ); + yield* Effect.forEach( + currentInstances.filter(({ id }) => active.has(id)), + (instance) => instance.ready, + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError(stackError), + ); + yield* Ref.set(startupComplete, true); + const endpoints = yield* reportEndpoints(currentInstances).pipe( + Effect.tapError((error) => starting.fail(error.message)), + ); + yield* starting.succeed("Stack is ready."); + yield* output.success("", { id: stack.id, endpoints }); + return stack.id; + } const fullyStopped = currentStatuses.every( ({ lifecycle, wakeEnabled }) => lifecycle === "stopped" && !wakeEnabled, ); @@ -673,18 +731,8 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ); yield* Effect.forEach(preparation, (fiber) => Fiber.join(fiber)); yield* Ref.set(startupComplete, true); - const endpoints = Object.fromEntries( - (yield* Effect.forEach(members, (member) => - member.status.pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - Effect.map((observation) => - Object.entries(endpointReports(observation)).map( - ([name, endpoint]) => [`${member.service}.${name}`, endpoint] as const, - ), - ), - ), - )).flat(), + const endpoints = yield* reportEndpoints(members).pipe( + Effect.tapError((error) => starting.fail(error.message)), ); yield* starting.succeed("Stack is ready."); yield* output.success("", { id: stack.id, endpoints }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 027d19ad08..2cfb8574f6 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1,7 +1,7 @@ import { generateKeyPairSync } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { Deferred, Effect, Fiber, FileSystem, Layer, Option, Redacted, Stream } from "effect"; import { DEFAULT_LOCAL_DATABASE_PASSWORD, DEFAULT_LOCAL_JWT_SECRET, @@ -64,8 +64,11 @@ const session: DbSession = { const instance = ( creation: ServiceCreation, id: string, - lifecycle: () => "stopped" | "running", + lifecycle: () => "stopped" | "starting" | "running", wakeEnabled: () => boolean, + health: () => "starting" | "healthy" | "unhealthy" | undefined = () => + lifecycle() === "running" ? "healthy" : undefined, + ready: () => Effect.Effect = () => Effect.void, ): ServiceInstances[ServiceCreation["service"]] => { const status = (config: ServiceCreation) => ({ id, @@ -75,7 +78,7 @@ const instance = ( : [], config, lifecycle: lifecycle(), - health: undefined, + health: health(), error: undefined, cleanupError: undefined, exit: undefined, @@ -88,7 +91,7 @@ const instance = ( const base = { id, start: Effect.void, - ready: Effect.void, + ready: Effect.suspend(ready), stop: Effect.void, restart: () => Effect.void, destroy: Effect.void, @@ -171,6 +174,12 @@ const requireConcreteCreation = (creation: ServiceCreationInput): ServiceCreatio }; }; +interface MemberStatus { + readonly lifecycle?: "stopped" | "starting" | "running"; + readonly wakeEnabled?: boolean; + readonly health?: "starting" | "healthy" | "unhealthy"; +} + const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { let members: Array = []; let stopped = 0; @@ -178,12 +187,11 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { let hostDestroyed = 0; let composed = 0; let catalogApplied = 0; + let compositionStarts = 0; let lifecycle: "stopped" | "running" = "stopped"; let activations = new Map(); - const memberStatuses = new Map< - string, - { readonly lifecycle?: "stopped" | "running"; readonly wakeEnabled?: boolean } - >(); + const memberStatuses = new Map(); + const memberReadiness = new Map>(); let savedCredentials: StackCredentials = { jwtSecret: DEFAULT_LOCAL_JWT_SECRET, postgresRootKey: DEFAULT_POSTGRES_ROOT_KEY, @@ -260,6 +268,12 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { () => memberStatuses.get(id)?.wakeEnabled ?? (lifecycle === "running" && activations.get(id) === "lazy"), + () => { + const status = memberStatuses.get(id); + if (status?.health !== undefined) return status.health; + return (status?.lifecycle ?? lifecycle) === "running" ? "healthy" : undefined; + }, + () => memberReadiness.get(id) ?? Effect.void, ); }); activations = new Map(members.map(({ id }) => [id, "eager"])); @@ -272,7 +286,9 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { start: compositionStart ?? Effect.sync(() => { + compositionStarts += 1; lifecycle = "running"; + memberStatuses.clear(); return []; }), stop: Effect.sync(() => { @@ -311,18 +327,22 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { get catalogApplied() { return catalogApplied; }, + get compositionStarts() { + return compositionStarts; + }, applyCatalog() { catalogApplied += 1; }, get savedCredentials() { return savedCredentials; }, - setMemberStatus( - id: string, - status: { readonly lifecycle?: "stopped" | "running"; readonly wakeEnabled?: boolean }, - ) { + setMemberStatus(id: string, status: MemberStatus) { memberStatuses.set(id, status); }, + /** Readiness survives composition start, which awaits only eager members. */ + setMemberReadiness(id: string, ready: Effect.Effect) { + memberReadiness.set(id, ready); + }, }; }; @@ -739,6 +759,133 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("rejects a stack whose database alone was started before reading changed config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-db-only-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "db-only"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + for (const member of fixture.members) + if (member.service !== "database") + fixture.setMemberStatus(member.id, { lifecycle: "stopped", wakeEnabled: false }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "db-only"\n[auth]\nsigning_keys_path = "missing-keys.json"\n', + ); + + const error = yield* stackStart(flags(["studio"])).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "lifecycle", + message: "The stack is in a partial lifecycle state", + suggestion: "Run supabase stack stop, then supabase stack start to recover the stack.", + }); + expect(fixture.compositionStarts).toBe(1); + expect(fixture.composed).toBe(1); + expect(fixture.stopped).toBe(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "reports a resumed stack ready only after its unhealthy and booting members are ready", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-resume-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "resume"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const rest = fixture.members.find(({ service }) => service === "rest"); + const auth = fixture.members.find(({ service }) => service === "auth"); + if (rest === undefined || auth === undefined) + return yield* Effect.die("Expected REST and Auth members"); + fixture.setMemberStatus(rest.id, { lifecycle: "running", health: "unhealthy" }); + fixture.setMemberStatus(auth.id, { lifecycle: "starting", health: "starting" }); + const gate = yield* Deferred.make(); + const awaited = yield* Effect.forEach([rest.id, auth.id], (id) => + Deferred.make().pipe( + Effect.tap((waiting) => + Effect.sync(() => + fixture.setMemberReadiness( + id, + Deferred.succeed(waiting, undefined).pipe(Effect.andThen(Deferred.await(gate))), + ), + ), + ), + ), + ); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "resume"\n[auth]\nsigning_keys_path = "missing-keys.json"\n', + ); + const output = mockOutput(); + + const resuming = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture, output)), + Effect.forkChild({ startImmediately: true }), + ); + const firstSettled = yield* Effect.raceFirst( + Fiber.join(resuming).pipe(Effect.as("reported" as const)), + Effect.forEach(awaited, Deferred.await, { discard: true }).pipe( + Effect.as("awaiting readiness" as const), + ), + ); + expect(firstSettled).toBe("awaiting readiness"); + expect(fixture.compositionStarts).toBe(2); + expect(output.messages).not.toContainEqual({ type: "success", message: "Stack is ready." }); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(resuming); + + expect(fixture.composed).toBe(1); + expect(fixture.stopped).toBe(0); + expect(output.messages).toContainEqual({ + type: "info", + message: + "Resuming the saved stack services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + }); + expect(output.messages).toContainEqual({ type: "success", message: "Stack is ready." }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("fails a start while a woken lazy member is still booting and never becomes ready", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-booting-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "booting"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const auth = fixture.members.find(({ service }) => service === "auth"); + if (auth === undefined) return yield* Effect.die("Expected an Auth member"); + fixture.setMemberStatus(auth.id, { lifecycle: "starting", health: "starting" }); + fixture.setMemberReadiness( + auth.id, + Effect.fail( + new StackError({ operation: "service.ready", message: "auth HTTP readiness timed out" }), + ), + ); + const output = mockOutput(); + + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture, output)), + Effect.flip, + ); + + expect(error).toBeInstanceOf(StackCommandStartError); + expect(error).toMatchObject({ message: "auth HTTP readiness timed out" }); + expect(output.messages.map(({ message }) => message)).not.toContain("Stack is ready."); + expect(output.messages.map(({ message }) => message)).not.toContain( + "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("rejects changed Postgres root keys and database versions after stopping the stack", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts index dcd68524fc..8ff71a8172 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts @@ -378,8 +378,11 @@ describe("experimental stack start native lifecycle", () => { const failedStart = yield* Effect.scoped(Effect.exit(stackStart(flags([])))); expect(Exit.isFailure(failedStart)).toBe(true); if (!Exit.isFailure(failedStart)) return; - expect(Cause.pretty(failedStart.cause)).toContain( - `:${occupiedPort}: Cannot bind TCP listener`, + // Linux rejects the bind; platforms that allow overlapping binds reject the probe. + expect(Cause.pretty(failedStart.cause)).toMatch( + new RegExp( + `127\\.0\\.0\\.1:${occupiedPort}(: Cannot bind TCP listener| is already in use)`, + ), ); const ownerPid = ownerPids[attempt]; expect(ownerPid).toBeDefined(); diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index df78baab24..845e99b5ed 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -20,7 +20,9 @@ Text confirms each successful shutdown and identifies each unavailable owner. JSON and stream-json success data contain `stopped` and `unavailable` ID arrays. A missing default selection reports `found: false`; an unknown explicit name or ID fails. `--all` attempts every selected reachable owner and reports failures -with their IDs. Corrupt registry state fails discovery without partial results. +with their IDs. State entries that cannot be read or decoded are skipped with a +warning on stderr identifying each stack; only a failure to read the stacks +directory itself fails discovery. ## Files and network diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index c76b9730d1..b2fd2ef858 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -92,7 +92,13 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: stateRoot: path.join(settings.supabaseHome, "stacks"), cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), }; - const discovered = yield* api.discover(locations).pipe(Effect.mapError(stopError)); + const discovered = yield* api + .discover({ + ...locations, + onInvalidState: (id, error) => + output.raw(`Warning: skipping invalid stack ${id}: ${error.message}\n`, "stderr"), + }) + .pipe(Effect.mapError(stopError)); const selected = all ? discovered : discovered.filter(({ definition }) => definition.id === target?.id); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index d3a2ce6a7f..0e4f36a7b9 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -76,6 +76,31 @@ describe("stack stop", () => { }).pipe(Effect.provide(live)), ); + it.live.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "stop all continues past siblings whose state cannot be decoded or accessed and warns about them", + () => + Effect.gen(function* () { + const f = yield* fixture(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const broken = path.join(f.locations.stateRoot, "broken"); + yield* fs.makeDirectory(broken); + yield* fs.writeFileString(path.join(broken, "state.json"), "{broken"); + const locked = path.join(f.locations.stateRoot, "locked"); + yield* fs.makeDirectory(locked); + yield* Effect.acquireRelease(fs.chmod(locked, 0o000), () => + fs.chmod(locked, 0o700).pipe(Effect.orDie), + ); + yield* stackStop({ ...flags(), all: Option.some(true) }).pipe(Effect.provide(f.layer)); + expect(f.output.stdoutText.match(/workload state is unavailable/g)).toHaveLength(1); + expect(f.output.stderrText).toContain("Warning: skipping invalid stack broken"); + expect(f.output.stderrText).toContain("Warning: skipping invalid stack locked"); + expect(f.output.stdoutText).not.toContain("Warning"); + expect(yield* fs.readFileString(path.join(broken, "state.json"))).toBe("{broken"); + expect(f.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(live)), + ); + it.live("surfaces a shutdown failure after a successful owner preflight", () => Effect.gen(function* () { const f = yield* fixture(); diff --git a/packages/stack/README.md b/packages/stack/README.md index b60143a22e..63bd492cf2 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -57,7 +57,7 @@ await stack.close(); // disconnects this client `start` and `ready` are separate operations. `restart({ config })` replaces recipe configuration while retaining the instance identity and endpoint intentions. A health failure leaves a launched process running and observable; it does not prevent `stop`. Database snapshots require a stopped instance with wake disabled. `saveSnapshot(key)` publishes complete data to managed backend storage and replaces the previous entry for that key; `restoreSnapshot(key)` returns `false` on a miss and `true` after restoring a compatible entry. Managed retention may evict older keys, while snapshots survive destruction of the source stack. Other service handles have no snapshot methods. -Creating a service records its definition. Configured public ports are bound during startup and retained across normal stop/start and owner reopening. An occupied saved port reports a conflict instead of moving. Omitted public endpoints are not exposed. +Creating a service records its definition. Configured public ports are bound during startup and retained across normal stop/start and owner reopening. An occupied saved port reports a conflict instead of moving. Automatic ports avoid numbers saved by any stack under the same `stateRoot`; a fixed port is decided by binding it, so another stack's saved port blocks only while something listens on it; that conflict names the stack that saved the port. Omitted public endpoints are not exposed. Native public listeners bind to loopback. Docker and Podman public proxies bind all interfaces so services inside the container network can reach them; those listeners are reachable from the LAN according to the host firewall. @@ -65,7 +65,7 @@ Functions configuration requires bootstrap source. Database versions belong in ` On Linux, native Functions project files must be outside `/tmp`: Edge Runtime uses a private filesystem at that path. Docker and Podman mount project files at a separate runtime path. -`open({ id, stateRoot, cacheRoot })` reconnects to a saved stack. The package stores the stack document at `//state.json` and service data at `//data/`. `discover({ stateRoot })` lists saved definitions and port assignments separately from live-owner availability. Offline definitions are not live lifecycle observations. +`open({ id, stateRoot, cacheRoot })` reconnects to a saved stack. The package stores the stack document at `//state.json` and service data at `//data/`. `discover({ stateRoot })` lists saved definitions and port assignments separately from live-owner availability. It skips each entry that cannot be read or decoded and reports it to `onInvalidState(id, error)`; only a failure to read `stateRoot` itself fails discovery. Port allocation skips the same entries. Offline definitions are not live lifecycle observations. Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. diff --git a/packages/stack/src/Commands.integration.test.ts b/packages/stack/src/Commands.integration.test.ts index 7b5f9cc8d9..aaee340e01 100644 --- a/packages/stack/src/Commands.integration.test.ts +++ b/packages/stack/src/Commands.integration.test.ts @@ -78,7 +78,7 @@ describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-tools-" }); - const stackId = "tools-integration"; + const stackId = `tools-integration-${randomUUID()}`; const database = yield* makeDatabase({ root, cacheRoot, @@ -224,7 +224,7 @@ describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-pgprove-${major}-` }); - const stackId = `tools-pgprove-${runtime}-${major}`; + const stackId = `tools-pgprove-${runtime}-${major}-${randomUUID()}`; const database = yield* makeDatabase({ root, cacheRoot, diff --git a/packages/stack/src/Orchestrator.integration.test.ts b/packages/stack/src/Orchestrator.integration.test.ts index 0759fd353d..93a2a075f1 100644 --- a/packages/stack/src/Orchestrator.integration.test.ts +++ b/packages/stack/src/Orchestrator.integration.test.ts @@ -29,6 +29,7 @@ const makeInstance = ( options: { readonly endpoint?: boolean; readonly health?: Effect.Effect; + readonly probe?: Effect.Effect; readonly bind?: Effect.Effect; readonly prepare?: Effect.Effect; readonly launch?: Effect.Effect; @@ -56,6 +57,7 @@ const makeInstance = ( const exited = yield* Deferred.make>(); return { health: options.health ?? Effect.void, + ...(options.probe === undefined ? {} : { probe: options.probe }), exit: Deferred.await(options.exit ?? exited), stop: (options.stop ?? Effect.void).pipe( Effect.andThen(event("stop")), @@ -796,3 +798,67 @@ it.live("allows later traffic to retry an armed service after a failed wake laun }), ), ); + +describe("readiness recovery", () => { + const recoverableHealth = (healthy: Ref.Ref) => + Ref.get(healthy).pipe( + Effect.flatMap((ok) => (ok ? Effect.void : Effect.fail(failure("still booting")))), + ); + + it.live("serves traffic once a running instance recovers without restarting it", () => + Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const healthy = yield* Ref.make(false); + const api = yield* makeInstance(orchestrator, "api", { + health: recoverableHealth(healthy), + probe: recoverableHealth(healthy), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "eager" }], + dependencies: [], + }); + yield* orchestrator.startComposition.pipe(Effect.flip); + yield* Effect.scoped(orchestrator.acquire("api")).pipe(Effect.flip); + + yield* Ref.set(healthy, true); + yield* Effect.scoped(orchestrator.acquire("api")); + + expect(yield* Ref.get(api.starts)).toHaveLength(1); + expect(yield* api.core.get).toMatchObject({ lifecycle: "running", health: "healthy" }); + yield* orchestrator.stopNamespace; + }), + ), + ); + + it.live("starts a blocked dependent once its running prerequisite recovers", () => + Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const healthy = yield* Ref.make(false); + const database = yield* makeInstance(orchestrator, "database", { + health: recoverableHealth(healthy), + probe: recoverableHealth(healthy), + }); + const rest = yield* makeInstance(orchestrator, "rest"); + yield* orchestrator.configure({ + members: [ + { id: "database", activation: "eager" }, + { id: "rest", activation: "eager" }, + ], + dependencies: [{ from: "database", to: "rest" }], + }); + yield* orchestrator.startComposition.pipe(Effect.flip); + expect(yield* Ref.get(rest.starts)).toEqual([]); + + yield* Ref.set(healthy, true); + yield* orchestrator.startComposition; + + expect(yield* Ref.get(database.starts)).toHaveLength(1); + expect(yield* Ref.get(rest.starts)).toHaveLength(1); + expect((yield* rest.core.get).health).toBe("healthy"); + yield* orchestrator.stopNamespace; + }), + ), + ); +}); diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index 87fea5b706..e7f0e2ddc5 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices, NodeSocketServer } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Effect, Exit, FileSystem, Layer, Scope } from "effect"; +import { Cause, Context, Effect, Exit, FileSystem, Layer, Option, Ref, Scope } from "effect"; import { makePorts, PortError } from "./Ports.ts"; import * as State from "./State.ts"; @@ -73,6 +73,7 @@ it.live("reports an occupied saved port without moving its assignment", () => const failure = yield* ports.acquire(request, bind).pipe(Effect.flip); expect(failure).toBeInstanceOf(PortError); expect(failure.message).toContain(`api at 127.0.0.1:${first.port}`); + expect(failure.message).not.toContain("claims this port"); expect((yield* state.read("stack"))?.ports[0]?.port).toBe(first.port); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -162,29 +163,255 @@ it.live("names the last bind failure when no public port is available", () => ).pipe(Effect.provide(NodeServices.layer)), ); -it.live("refuses allocation when another stack has unreadable claims", () => +const saveStack = ( + state: State.Interface, + root: string, + id: string, + ports: State.SavedStack["ports"] = [], +) => + state.save({ + id, + runtime: "native", + identity: { projectRoot: root, branchContext: `branch-${id}`, stackName: id }, + instances: [], + composition: { members: [], dependencies: [] }, + ports, + }); + +it.live("allocates past siblings whose state is unreadable or from a newer format", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped(); const state = yield* makeTestState(root); - yield* state.save({ - id: "healthy", - runtime: "native", - identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], - }); + yield* saveStack(state, root, "healthy"); yield* fs.makeDirectory(`${root}/broken`); yield* fs.writeFileString(`${root}/broken/state.json`, "{broken"); + yield* fs.makeDirectory(`${root}/newer`); + yield* fs.writeFileString( + `${root}/newer/state.json`, + '{"id":"newer","runtime":"future","ports":[]}', + ); const ports = yield* makePorts(state); - const error = yield* ports - .acquire({ stackId: "healthy", key: "sql", host: "127.0.0.1", port: "auto" }, bind) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(State.StateError); - expect((yield* state.read("healthy"))?.ports).toEqual([]); + const acquired = yield* ports.acquire( + { stackId: "healthy", key: "sql", host: "127.0.0.1", port: "auto" }, + bind, + ); + expect((yield* state.read("healthy"))?.ports).toEqual([ + { key: "sql", host: "127.0.0.1", port: acquired.port }, + ]); expect(yield* fs.readFileString(`${root}/broken/state.json`)).toBe("{broken"); }), ).pipe(Effect.provide(NodeServices.layer)), ); + +it.live("keeps auto allocation off ports claimed by a sibling in a newer format", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "stack"); + const ports = yield* makePorts(state); + const request = { stackId: "stack", key: "api", host: "127.0.0.1", port: "auto" as const }; + const accept = (_host: string, port: number) => Effect.succeed(port); + const preferred = yield* ports.acquire(request, accept); + yield* ports.release("stack", "api"); + yield* fs.makeDirectory(`${root}/newer`); + yield* fs.writeFileString( + `${root}/newer/state.json`, + `{"id":"newer","runtime":"future","ports":[{"key":"api","host":"127.0.0.1","port":${preferred.port}}]}`, + ); + const moved = yield* ports.acquire(request, accept); + expect(moved.port).not.toBe(preferred.port); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lets a stack bind an explicit port that a stopped stack still claims", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "stopped"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state); + const stoppedScope = yield* Scope.make(); + const stopped = yield* ports + .acquire({ stackId: "stopped", key: "db/sql", host: "127.0.0.1", port: "auto" }, bind) + .pipe(Effect.provideService(Scope.Scope, stoppedScope)); + yield* Scope.close(stoppedScope, Exit.void); + + const current = yield* ports.acquire( + { stackId: "current", key: "db/sql", host: "127.0.0.1", port: stopped.port }, + bind, + ); + expect(current.port).toBe(stopped.port); + expect((yield* state.read("stopped"))?.ports).toEqual([ + { key: "db/sql", host: "127.0.0.1", port: stopped.port }, + ]); + expect((yield* state.read("current"))?.ports).toEqual([ + { key: "db/sql", host: "127.0.0.1", port: stopped.port }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("names the stack claiming an explicit port that a live listener holds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "holder"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state); + const held = yield* ports.acquire( + { stackId: "holder", key: "db/sql", host: "127.0.0.1", port: "auto" }, + bind, + ); + + const failure = yield* ports + .acquire({ stackId: "current", key: "db/sql", host: "127.0.0.1", port: held.port }, bind) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(PortError); + expect(failure.message).toContain(`db/sql at 127.0.0.1:${held.port}`); + expect(failure.message).toContain(`stack "holder" on branch-holder in ${root}`); + expect((yield* state.read("current"))?.ports).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +for (const [held, requested] of [ + ["127.0.0.1", "0.0.0.0"], + ["::1", "127.0.0.1"], +] as const) + it.live(`rejects ${requested} on a port a ${held} listener holds where binds can overlap`, () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "holder"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state, "darwin"); + const listener = yield* ports.acquire( + { stackId: "holder", key: "api", host: held, port: "auto" }, + bind, + ); + const overlappingBinds = yield* Ref.make(0); + + const failure = yield* ports + .acquire({ stackId: "current", key: "api", host: requested, port: listener.port }, () => + Ref.update(overlappingBinds, (count) => count + 1), + ) + .pipe(Effect.flip); + expect(failure.message).toContain("already in use"); + expect(failure.message).toContain(`stack "holder"`); + expect(yield* Ref.get(overlappingBinds)).toBe(0); + expect((yield* state.read("current"))?.ports).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + +it.live("leaves a fixed port to the bind where overlapping binds are rejected", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "holder"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state, "linux"); + const listener = yield* ports.acquire( + { stackId: "holder", key: "api", host: "127.0.0.1", port: "auto" }, + bind, + ); + const binds = yield* Ref.make(0); + + const acquired = yield* ports.acquire( + { stackId: "current", key: "api", host: "0.0.0.0", port: listener.port }, + () => Ref.update(binds, (count) => count + 1), + ); + expect(acquired.port).toBe(listener.port); + expect(yield* Ref.get(binds)).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lets a stack bind a port that a running stack claims but does not listen on", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "running"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state); + yield* ports.acquire( + { stackId: "running", key: "control", host: "127.0.0.1", port: "auto" }, + bind, + ); + const restScope = yield* Scope.make(); + const rest = yield* ports + .acquire({ stackId: "running", key: "api", host: "127.0.0.1", port: "auto" }, bind) + .pipe(Effect.provideService(Scope.Scope, restScope)); + yield* Scope.close(restScope, Exit.void); + + const current = yield* ports.acquire( + { stackId: "current", key: "api", host: "127.0.0.1", port: rest.port }, + bind, + ); + expect(current.port).toBe(rest.port); + expect((yield* state.read("current"))?.ports).toEqual([ + { key: "api", host: "127.0.0.1", port: rest.port }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lets exactly one of two stacks sharing a saved port bind it when both start at once", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "first"); + const ports = yield* makePorts(state); + const request = { stackId: "first", key: "api", host: "127.0.0.1", port: "auto" as const }; + const seedScope = yield* Scope.make(); + const seed = yield* ports + .acquire(request, bind) + .pipe(Effect.provideService(Scope.Scope, seedScope)); + yield* Scope.close(seedScope, Exit.void); + yield* saveStack(state, root, "second", [{ key: "api", host: "127.0.0.1", port: seed.port }]); + + const [first, second] = yield* Effect.all( + [ + Effect.exit(ports.acquire(request, bind)), + Effect.exit(ports.acquire({ ...request, stackId: "second" }, bind)), + ], + { concurrency: "unbounded" }, + ); + const outcomes = [ + { claimant: "second", exit: first }, + { claimant: "first", exit: second }, + ]; + expect(outcomes.filter(({ exit }) => Exit.isSuccess(exit))).toHaveLength(1); + const loser = outcomes.find(({ exit }) => Exit.isFailure(exit)); + const failure = + loser !== undefined && Exit.isFailure(loser.exit) + ? Cause.findErrorOption(loser.exit.cause) + : Option.none(); + if (Option.isNone(failure)) return yield* Effect.die("expected a port conflict"); + expect(failure.value).toBeInstanceOf(PortError); + expect(failure.value.message).toContain(`127.0.0.1:${seed.port}`); + expect(failure.value.message).toContain(`stack "${loser?.claimant}"`); + for (const id of ["first", "second"]) + expect((yield* state.read(id))?.ports).toEqual([ + { key: "api", host: "127.0.0.1", port: seed.port }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/Ports.ts b/packages/stack/src/Ports.ts index 4109ed6424..6cb61ce842 100644 --- a/packages/stack/src/Ports.ts +++ b/packages/stack/src/Ports.ts @@ -1,4 +1,5 @@ import { Cause, Data, Effect, Exit, Hash, Option, Scope } from "effect"; +import * as Net from "node:net"; import type * as State from "./State.ts"; const portBase = 20000; @@ -24,112 +25,220 @@ export interface PortRequest { const scanStart = (stack: State.SavedStack, key: string) => Math.abs(Hash.string(`${stack.identity.projectRoot}:${stack.id}:${key}`)) % portSpan; -/** Coordinates durable public claims while retaining each successfully bound listener. */ -export const makePorts = (state: State.Interface) => +/** A wildcard listener is reachable through loopback, where a same-port loopback listener answers too. */ +const probeHost = (host: string) => + host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host; + +/** A refused loopback connect can take seconds on Windows, so silence within the bound counts as vacant. */ +export const accepts = (host: string, port: number) => + Effect.callback((resume) => { + const socket = Net.connect({ host: probeHost(host), port }); + const settle = (listening: boolean) => { + socket.destroy(); + resume(Effect.succeed(listening)); + }; + socket.once("connect", () => settle(true)); + socket.on("error", () => settle(false)); + return Effect.sync(() => { + socket.destroy(); + }); + }).pipe(Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed(false) })); + +/** Linux rejects a bind that overlaps a same-family listener on the port; macOS, BSD, and Windows accept it. */ +const bindsCanOverlap = (platform: NodeJS.Platform) => platform !== "linux"; + +const loopbackOccupied = (port: number) => + Effect.forEach(["127.0.0.1", "::1"], (host) => accepts(host, port), { + concurrency: "unbounded", + }).pipe(Effect.map((answers) => answers.some(Boolean))); + +const claimantOf = (stacks: ReadonlyArray, stackId: string, port: number) => + stacks.find((other) => other.id !== stackId && other.ports.some((claim) => claim.port === port)) + ?.id; + +const resolveRequest = ( + stack: State.SavedStack, + request: PortRequest, +): Effect.Effect< + { readonly saved: State.PortClaim | undefined; readonly requested: number | "auto" }, + PortError +> => { + const saved = stack.ports.find((entry) => entry.key === request.key); + if ( + saved !== undefined && + (saved.host !== request.host || (request.port !== "auto" && saved.port !== request.port)) + ) + return Effect.fail( + new PortError({ + key: request.key, + message: "The requested listener differs from its saved assignment", + }), + ); + const requested = saved?.port ?? request.port; + if (requested === "auto") return Effect.succeed({ saved, requested }); + if (!Number.isInteger(requested) || requested < 1 || requested > 65535) + return Effect.fail(new PortError({ key: request.key, message: "Invalid public port" })); + if (stack.ports.some((claim) => claim.key !== request.key && claim.port === requested)) + return Effect.fail( + new PortError({ + key: request.key, + message: `Public port ${requested} is claimed by another listener of this stack`, + }), + ); + return Effect.succeed({ saved, requested }); +}; + +/** Claims steer auto allocation away from saved stacks; live listeners and binds decide conflicts for fixed ports. */ +export const makePorts = (state: State.Interface, platform: NodeJS.Platform = process.platform) => Effect.sync(() => { - const acquire = Effect.fn("Ports.acquire")( - ( - request: PortRequest, - bind: (host: string, port: number) => Effect.Effect, - ) => - state.withLock( - Effect.gen(function* () { - const stack = yield* state.read(request.stackId); - if (stack === undefined) - return yield* new PortError({ key: request.key, message: "Stack is not registered" }); - const saved = stack.ports.find((entry) => entry.key === request.key); - if ( - saved !== undefined && - (saved.host !== request.host || - (request.port !== "auto" && saved.port !== request.port)) - ) - return yield* new PortError({ - key: request.key, - message: "The requested listener differs from its saved assignment", - }); - const requested = saved?.port ?? request.port; + const describe = (id: string) => + state.read(id).pipe( + Effect.map((saved) => + saved === undefined + ? id + : `"${saved.identity.stackName}" on ${saved.identity.branchContext} in ${saved.identity.projectRoot}`, + ), + Effect.orElseSucceed(() => id), + ); + + const claimedBy = (stacks: ReadonlyArray, stackId: string, port: number) => { + const claimant = claimantOf(stacks, stackId, port); + return claimant === undefined + ? Effect.succeed("") + : describe(claimant).pipe(Effect.map((stack) => `; stack ${stack} claims this port`)); + }; + + // A caller may serve several claims of one stack from a listener it acquired here, so that + // listener is not a foreign occupant. + const held = new Map(); + const hold = (scope: Scope.Scope, stackId: string, port: number) => { + const key = `${stackId}:${port}`; + return Effect.sync(() => held.set(key, (held.get(key) ?? 0) + 1)).pipe( + Effect.andThen( + Scope.addFinalizer( + scope, + Effect.sync(() => { + const remaining = (held.get(key) ?? 1) - 1; + if (remaining > 0) held.set(key, remaining); + else held.delete(key); + }), + ), + ), + ); + }; + + /** Advisory and outside the registry lock; the bind under the lock still decides. */ + const rejectOccupied = Effect.fn("Ports.rejectOccupied")(function* (request: PortRequest) { + const preview = yield* state.read(request.stackId); + if (preview === undefined) return; + const { requested } = yield* resolveRequest(preview, request); + if ( + requested === "auto" || + !bindsCanOverlap(platform) || + held.has(`${request.stackId}:${requested}`) || + !(yield* loopbackOccupied(requested)) + ) + return; + const message = `Public port ${requested} for ${request.key} at ${request.host}:${requested} is already in use`; + return yield* new PortError({ + key: request.key, + message: `${message}${yield* claimedBy(yield* state.claims, request.stackId, requested)}`, + // Owners classify an occupied port by this errno, as they do for a failed bind. + cause: Object.assign(new Error(message), { code: "EADDRINUSE" }), + }); + }); + + const acquire = Effect.fn("Ports.acquire")(function* ( + request: PortRequest, + bind: (host: string, port: number) => Effect.Effect, + ) { + yield* rejectOccupied(request); + return yield* state.withLock( + Effect.gen(function* () { + const stack = yield* state.read(request.stackId); + if (stack === undefined) + return yield* new PortError({ key: request.key, message: "Stack is not registered" }); + const { saved, requested } = yield* resolveRequest(stack, request); + const others = yield* state.claims; + const claimed = new Set([ + ...stack.ports.filter((claim) => claim.key !== request.key).map((claim) => claim.port), + ...others + .filter((other) => other.id !== request.stackId) + .flatMap((other) => other.ports.map((claim) => claim.port)), + ]); + + const owner = yield* Scope.Scope; + const start = scanStart(stack, request.key); + let failures = 0; + let lastFailure: PortError | undefined; + for (let attempt = 0; attempt < portSpan && failures < 64; attempt++) { + const port = + requested === "auto" + ? portBase + ((start + attempt * portStride) % portSpan) + : requested; + if (requested === "auto" && claimed.has(port)) continue; + const result = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const scope = yield* Scope.fork(owner, "sequential"); + return yield* restore( + Effect.gen(function* () { + const listener = yield* bind(request.host, port).pipe( + Effect.mapError( + (cause) => + new PortError({ + key: request.key, + message: `Cannot bind ${request.key} at ${request.host}:${port}: ${cause.message}`, + cause, + }), + ), + Effect.provideService(Scope.Scope, scope), + ); + if (saved === undefined) + yield* state.save({ + ...stack, + ports: [...stack.ports, { key: request.key, host: request.host, port }], + }); + return { port, listener }; + }), + ).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? Scope.close(scope, exit) + : hold(scope, request.stackId, port), + ), + Effect.exit, + ); + }), + ); + if (Exit.isSuccess(result)) return result.value; + const error = Cause.findErrorOption(result.cause); if ( - requested !== "auto" && - (!Number.isInteger(requested) || requested < 1 || requested > 65535) + Exit.hasInterrupts(result) || + Exit.hasDies(result) || + Option.isNone(error) || + !(error.value instanceof PortError) ) - return yield* new PortError({ key: request.key, message: "Invalid public port" }); - - const claimed = new Set(); - for (const other of yield* state.list) - for (const claim of other.ports) - if (other.id !== request.stackId || claim.key !== request.key) - claimed.add(claim.port); - if (requested !== "auto" && claimed.has(requested)) + return yield* Effect.failCause(result.cause); + if (requested !== "auto") return yield* new PortError({ key: request.key, - message: `Public port ${requested} is claimed by another listener`, + message: `${error.value.message}${yield* claimedBy(others, request.stackId, requested)}`, + cause: error.value.cause, }); - - const owner = yield* Scope.Scope; - const start = scanStart(stack, request.key); - let failures = 0; - let lastFailure: PortError | undefined; - for (let attempt = 0; attempt < portSpan && failures < 64; attempt++) { - const port = - requested === "auto" - ? portBase + ((start + attempt * portStride) % portSpan) - : requested; - if (claimed.has(port)) continue; - const result = yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const scope = yield* Scope.fork(owner, "sequential"); - return yield* restore( - Effect.gen(function* () { - const listener = yield* bind(request.host, port).pipe( - Effect.mapError( - (cause) => - new PortError({ - key: request.key, - message: `Cannot bind ${request.key} at ${request.host}:${port}: ${cause.message}`, - cause, - }), - ), - Effect.provideService(Scope.Scope, scope), - ); - if (saved === undefined) - yield* state.save({ - ...stack, - ports: [...stack.ports, { key: request.key, host: request.host, port }], - }); - return { port, listener }; - }), - ).pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void, - ), - Effect.exit, - ); - }), - ); - if (Exit.isSuccess(result)) return result.value; - const error = Cause.findErrorOption(result.cause); - if ( - requested !== "auto" || - Exit.hasInterrupts(result) || - Exit.hasDies(result) || - Option.isNone(error) || - !(error.value instanceof PortError) - ) - return yield* Effect.failCause(result.cause); - failures++; - lastFailure = error.value; - } - return yield* new PortError({ - key: request.key, - message: - lastFailure === undefined - ? "No public port is available" - : `No public port is available: ${lastFailure.message}`, - cause: lastFailure, - }); - }), - ), - ); + failures++; + lastFailure = error.value; + } + return yield* new PortError({ + key: request.key, + message: + lastFailure === undefined + ? "No public port is available" + : `No public port is available: ${lastFailure.message}`, + cause: lastFailure, + }); + }), + ); + }); const release = Effect.fn("Ports.release")(function* (stackId: string, key: string) { yield* state.withLock( diff --git a/packages/stack/src/Service.integration.test.ts b/packages/stack/src/Service.integration.test.ts index 16b104c005..2769658da9 100644 --- a/packages/stack/src/Service.integration.test.ts +++ b/packages/stack/src/Service.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Stream } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Scope, Stream } from "effect"; import { makeService, ServiceDestroyed, @@ -1110,3 +1110,244 @@ it.live("keeps a sleeping instance armed when exit observation retries failed re }), ), ); + +const unhealthy = (message: string) => new ServiceError({ operation: "health", message }); + +const makeProbedService = ( + check: (launch: number) => Effect.Effect, + options: { readonly probe: boolean } = { probe: true }, +) => + Effect.gen(function* () { + const launches = yield* Ref.make(0); + const service = yield* makeService( + { + launch: () => + Ref.updateAndGet(launches, (count) => count + 1).pipe( + Effect.map((launch): RuntimeSession => ({ + health: check(launch), + ...(options.probe ? { probe: check(launch) } : {}), + exit: Effect.never, + stop: Effect.void, + remove: Effect.void, + })), + ), + removeData: () => Effect.void, + }, + { id: "probed", config: { version: 1 } }, + ); + return { service, launches }; + }); + +const startUnhealthy = (service: ServiceInstance) => + Effect.gen(function* () { + const failed = yield* waitForObservation(service, (value) => value.health === "unhealthy"); + yield* service.start; + yield* Fiber.join(failed); + }); + +describe("service readiness recovery", () => { + it.live("serves readiness once a running launch recovers from a failed health check", () => + Effect.scoped( + Effect.gen(function* () { + const healthy = yield* Ref.make(false); + const { service, launches } = yield* makeProbedService(() => + Ref.get(healthy).pipe( + Effect.flatMap((ok) => (ok ? Effect.void : Effect.fail(unhealthy("still booting")))), + ), + ); + yield* startUnhealthy(service); + expect(yield* Effect.flip(service.ready)).toMatchObject({ message: "still booting" }); + + yield* Ref.set(healthy, true); + yield* service.ready; + + expect(yield* service.get).toMatchObject({ + lifecycle: "running", + health: "healthy", + error: undefined, + launchId: 1, + }); + expect(yield* Ref.get(launches)).toBe(1); + yield* service.stop; + }), + ), + ); + + it.live("reports the initial check's failure to callers waiting on it without re-probing", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const checkStarted = yield* Deferred.make(); + const checkGate = yield* Deferred.make(); + const { service } = yield* makeProbedService(() => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? open(checkStarted).pipe( + Effect.andThen(Deferred.await(checkGate)), + Effect.andThen(Effect.fail(unhealthy("rest HTTP readiness timed out"))), + ) + : Effect.fail(unhealthy("probe replaced the initial failure")), + ), + ), + ); + yield* service.start; + yield* Deferred.await(checkStarted); + const waiting = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + + yield* open(checkGate); + + expect(yield* Fiber.join(waiting)).toMatchObject({ + message: "rest HTTP readiness timed out", + }); + expect(yield* Ref.get(checks)).toBe(1); + expect(yield* service.get).toMatchObject({ + health: "unhealthy", + error: { message: "rest HTTP readiness timed out" }, + }); + yield* service.stop; + }), + ), + ); + + it.live("keeps a failed check final for a session without a probe", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const { service } = yield* makeProbedService( + () => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 ? Effect.fail(unhealthy("setup failed")) : Effect.void, + ), + ), + { probe: false }, + ); + yield* startUnhealthy(service); + + expect(yield* Effect.flip(service.ready)).toMatchObject({ message: "setup failed" }); + expect(yield* Effect.flip(service.ready)).toMatchObject({ message: "setup failed" }); + expect(yield* Ref.get(checks)).toBe(1); + yield* service.stop; + }), + ), + ); + + it.live("shares one re-probe among concurrent readiness callers", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const probeGate = yield* Deferred.make(); + const { service } = yield* makeProbedService(() => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(unhealthy("first check failed")) + : count === 2 + ? open(probeStarted).pipe( + Effect.andThen(Deferred.await(probeGate)), + Effect.andThen(Effect.fail(unhealthy("still booting"))), + ) + : Effect.void, + ), + ), + ); + yield* startUnhealthy(service); + + const first = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + const second = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + yield* Deferred.await(probeStarted); + yield* open(probeGate); + + expect(yield* Fiber.join(first)).toMatchObject({ message: "still booting" }); + expect(yield* Fiber.join(second)).toMatchObject({ message: "still booting" }); + expect(yield* Ref.get(checks)).toBe(2); + yield* service.ready; + expect(yield* Ref.get(checks)).toBe(3); + yield* service.stop; + }), + ), + ); + + it.live("interrupts a superseded launch's probe and reports only the new launch", () => + Effect.scoped( + Effect.gen(function* () { + const firstLaunchChecks = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const probeInterrupted = yield* Deferred.make(); + const { service } = yield* makeProbedService((launch) => + launch === 1 + ? Ref.updateAndGet(firstLaunchChecks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(unhealthy("first launch unhealthy")) + : open(probeStarted).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => open(probeInterrupted)), + ), + ), + ) + : Effect.fail(unhealthy("second launch unhealthy")), + ); + yield* startUnhealthy(service); + const stale = yield* Effect.forkChild(service.ready, { startImmediately: true }); + yield* Deferred.await(probeStarted); + + const relaunched = yield* waitForObservation( + service, + (value) => value.launchId === 2 && value.health === "unhealthy", + ); + yield* service.restart(); + yield* Fiber.join(relaunched); + + yield* Deferred.await(probeInterrupted); + expect(Exit.isFailure(yield* Fiber.await(stale))).toBe(true); + expect(yield* service.get).toMatchObject({ + launchId: 2, + health: "unhealthy", + error: { message: "second launch unhealthy" }, + }); + expect(yield* Effect.flip(service.ready)).toMatchObject({ + message: "second launch unhealthy", + }); + expect(yield* Ref.get(firstLaunchChecks)).toBe(2); + yield* service.stop; + }), + ), + ); + + it.live("releases readiness waiters when the owner scope closes during a probe", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const owner = yield* Scope.make(); + const { service } = yield* makeProbedService(() => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(unhealthy("first check failed")) + : open(probeStarted).pipe(Effect.andThen(Effect.never)), + ), + ), + ).pipe(Scope.provide(owner)); + yield* startUnhealthy(service).pipe(Scope.provide(owner)); + const waiting = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + yield* Deferred.await(probeStarted); + + yield* Scope.close(owner, Exit.void); + + expect(yield* Fiber.join(waiting)).toMatchObject({ message: "Session stopped" }); + }), + ), + ); +}); diff --git a/packages/stack/src/Service.ts b/packages/stack/src/Service.ts index a9f16d5ba1..d711c7faa6 100644 --- a/packages/stack/src/Service.ts +++ b/packages/stack/src/Service.ts @@ -61,8 +61,10 @@ export class ServiceStaleLaunch extends Data.TaggedError("ServiceStaleLaunch")<{ /** The exact runtime resources owned by one service launch. */ export interface RuntimeSession { - /** The one readiness program for this session. */ + /** The initial readiness program for this session. */ readonly health: Effect.Effect; + /** Bounded re-check for a running session whose last check failed; without it the failure is final. */ + readonly probe?: Effect.Effect; /** Resolves with the runtime's terminal result and remains attached to this session. */ readonly exit: Effect.Effect>; readonly stop: Effect.Effect; @@ -130,16 +132,21 @@ export interface ServiceInstance { readonly destroy: Effect.Effect; } +type HealthCheck = Deferred.Deferred>; + interface SessionRecord { readonly launchId: number; readonly runtime: RuntimeSession; readonly scope: Scope.Closeable; readonly healthScope: Scope.Closeable; - readonly health: Deferred.Deferred>; + /** The latest readiness check of this launch; a settled failure is replaced by an on-demand probe. */ + readonly health: Ref.Ref; readonly stopped: Ref.Ref; readonly removed: Ref.Ref; } +const sessionStopped = () => new ServiceError({ operation: "health", message: "Session stopped" }); + const contextFor = ( id: string, config: Config, @@ -213,6 +220,56 @@ export const makeService = ( options.coordinate ?? ((_operation: ServiceAdmission, transition: Effect.Effect) => transition); + // Health and exit settlement each write the observation and launchError as one step. + const settlement = yield* Semaphore.make(1); + + const settleCheck = Effect.fn("Service.settleCheck")(function* ( + record: SessionRecord, + exit: Exit.Exit, + ) { + const error = exitError("health", exit); + yield* settlement.withPermit( + Effect.gen(function* () { + const owned = yield* SubscriptionRef.modify( + observations, + (observation): [boolean, ServiceObservation] => + observation.launchId === record.launchId && observation.lifecycle === "running" + ? [ + true, + { + ...observation, + health: error === undefined ? "healthy" : "unhealthy", + error, + }, + ] + : [false, observation], + ); + if (owned) + yield* Ref.set( + launchError, + error === undefined ? undefined : { launchId: record.launchId, error }, + ); + }), + ); + }); + + /** Runs one readiness check whose result is shared by every `ready` caller awaiting it. */ + const runCheck = Effect.fn("Service.runCheck")( + (record: SessionRecord, check: Effect.Effect, result: HealthCheck) => + Effect.forkIn( + check.pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Deferred.succeed(result, Exit.fail(sessionStopped())) + : settleCheck(record, exit).pipe(Effect.andThen(Deferred.succeed(result, exit))), + ), + ), + record.healthScope, + // Starting immediately installs the settlement before a closing scope can interrupt it. + { startImmediately: true, uninterruptible: false }, + ), + ); + // Mask only the permit handoff; admitted work belongs to the host scope. const run = Effect.fn("Service.run")(function* < A, @@ -245,10 +302,7 @@ export const makeService = ( } yield* update({ lifecycle: "stopping", health: undefined, wakeEnabled: retainWake }); - yield* Deferred.succeed( - record.health, - Exit.fail(new ServiceError({ operation: "health", message: "Session stopped" })), - ); + yield* Deferred.succeed(yield* Ref.get(record.health), Exit.fail(sessionStopped())); yield* Scope.close(record.healthScope, Exit.void); if (!(yield* Ref.get(record.stopped))) { const halt = @@ -353,7 +407,7 @@ export const makeService = ( runtime, scope: runtimeScope, healthScope: yield* Scope.fork(runtimeScope, "parallel"), - health, + health: yield* Ref.make(health), stopped: yield* Ref.make(false), removed: yield* Ref.make(false), }; @@ -368,55 +422,34 @@ export const makeService = ( config: yield* Ref.get(config), }); - const observeHealth = runtime.health.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - const error = exitError("health", exit); - const currentObservation = yield* SubscriptionRef.get(observations); - if ( - error !== undefined && - currentObservation.launchId === launchId && - currentObservation.lifecycle === "running" - ) - yield* Ref.set(launchError, { launchId, error }); - yield* SubscriptionRef.update( - observations, - (observation): ServiceObservation => { - if (observation.launchId !== launchId || observation.lifecycle !== "running") - return observation; - return { - ...observation, - health: Exit.isSuccess(exit) ? "healthy" : "unhealthy", - error, - }; - }, - ); - yield* Deferred.succeed(record.health, exit); - }), - ), - ); const observeExit = record.runtime.exit.pipe( Effect.flatMap((exit) => Effect.gen(function* () { - if ((yield* Ref.get(current)) !== record) return; - const observation = yield* SubscriptionRef.get(observations); - const error = - observation.lifecycle === "stopping" - ? observation.error - : (exitError("exit", exit) ?? - new ServiceError({ - operation: "exit", - message: "Runtime exited unexpectedly", - })); - if (error !== undefined) - yield* Ref.set(launchError, { launchId: record.launchId, error }); - yield* update({ - lifecycle: "stopping", - health: undefined, - error, - exit, - wakeEnabled: observation.lifecycle === "stopping" && observation.wakeEnabled, - }); + const owned = yield* settlement.withPermit( + Effect.gen(function* () { + if ((yield* Ref.get(current)) !== record) return false; + const observation = yield* SubscriptionRef.get(observations); + const error = + observation.lifecycle === "stopping" + ? observation.error + : (exitError("exit", exit) ?? + new ServiceError({ + operation: "exit", + message: "Runtime exited unexpectedly", + })); + if (error !== undefined) + yield* Ref.set(launchError, { launchId: record.launchId, error }); + yield* update({ + lifecycle: "stopping", + health: undefined, + error, + exit, + wakeEnabled: observation.lifecycle === "stopping" && observation.wakeEnabled, + }); + return true; + }), + ); + if (!owned) return; yield* run( Effect.gen(function* () { if ((yield* Ref.get(current)) !== record) return; @@ -430,11 +463,10 @@ export const makeService = ( }), ), ); - if (launchFailure === undefined) - yield* Effect.forkIn(observeHealth, record.healthScope, { uninterruptible: false }); + if (launchFailure === undefined) yield* runCheck(record, runtime.health, health); yield* Effect.forkIn(observeExit, runtimeScope, { uninterruptible: false }); if (launchFailure !== undefined) { - yield* Deferred.succeed(record.health, Exit.fail(launchFailure)); + yield* Deferred.succeed(health, Exit.fail(launchFailure)); yield* stopNow(undefined, observation.wakeEnabled); return yield* launchFailure; } @@ -589,6 +621,30 @@ export const makeService = ( if (owned !== undefined && owned.launchId === launchId) return yield* owned.error; return yield* staleLaunch(launchId, `Service ${options.id} stopped before it was ready`); }); + const isLive = (record: SessionRecord) => + Effect.gen(function* () { + if ((yield* Ref.get(current)) !== record) return false; + return (yield* SubscriptionRef.get(observations)).lifecycle === "running"; + }); + + /** Concurrent callers share one probe per settled failure of a launch. */ + const reprobe = Effect.fn("Service.reprobe")(function* ( + record: SessionRecord, + failed: HealthCheck, + probe: Effect.Effect, + ) { + const next = yield* Deferred.make>(); + const check = yield* Ref.modify(record.health, (latest): [HealthCheck, HealthCheck] => + latest === failed ? [next, next] : [latest, latest], + ); + if (check === next) { + // stopNow fails the latest check after leaving "running", so a probe admitted here is always settled. + if (yield* isLive(record)) yield* runCheck(record, probe, next); + else yield* Deferred.succeed(next, Exit.fail(sessionStopped())); + } + return yield* Deferred.await(check); + }); + const ready = Effect.fn("Service.ready")(function* () { yield* Effect.annotateCurrentSpan({ member_id: options.id }); const initial = yield* SubscriptionRef.get(observations); @@ -616,14 +672,16 @@ export const makeService = ( if ((yield* SubscriptionRef.get(observations)).lifecycle === "stopping") { return yield* diedBeforeReady(launchId); } - const healthResult = yield* Deferred.await(record.health); - if ( - (yield* Ref.get(current)) !== record || - (yield* SubscriptionRef.get(observations)).lifecycle !== "running" - ) { - return yield* diedBeforeReady(launchId); - } - yield* healthResult; + const check = yield* Ref.get(record.health); + const settledOnArrival = yield* Deferred.isDone(check); + const healthResult = yield* Deferred.await(check); + if (!(yield* isLive(record))) return yield* diedBeforeReady(launchId); + const probe = record.runtime.probe; + if (Exit.isSuccess(healthResult) || !settledOnArrival || probe === undefined) + return yield* healthResult; + const reprobed = yield* reprobe(record, check, probe); + if (!(yield* isLive(record))) return yield* diedBeforeReady(launchId); + return yield* reprobed; }); const storage = Effect.fn("Service.storage")(function* ( diff --git a/packages/stack/src/State.integration.test.ts b/packages/stack/src/State.integration.test.ts index a4a4627b91..532f9cc57b 100644 --- a/packages/stack/src/State.integration.test.ts +++ b/packages/stack/src/State.integration.test.ts @@ -298,6 +298,157 @@ describe("durable stack state", () => { ), ); + const listingWithReadFailures = (options: { + readonly root: string; + readonly platform: NodeJS.Platform; + readonly code: string; + readonly failures: number; + }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const target = (yield* Path.Path).join(options.root, initial.id, "state.json"); + const remaining = yield* Ref.make(options.failures); + const firstFailure = yield* Deferred.make(); + const reported = yield* Ref.make>([]); + const injectedFs = Layer.succeed(FileSystem.FileSystem, { + ...fs, + readFileString: (file: string, encoding?: string) => + Effect.gen(function* () { + if (file === target && (yield* Ref.get(remaining)) > 0) { + yield* Ref.update(remaining, (count) => count - 1); + yield* Deferred.succeed(firstFailure, undefined); + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: file, + cause: Object.assign(new Error("injected read failure"), { code: options.code }), + }); + } + return yield* fs.readFileString(file, encoding); + }), + }); + const store = yield* Layer.build( + State.layer({ + root: options.root, + platform: options.platform, + onInvalidState: (id) => Ref.update(reported, (ids) => [...ids, id]), + }).pipe(Layer.provide(injectedFs)), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + return { store, firstFailure, reported }; + }); + + it.effect("lists a stack after a transient Windows read failure clears", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-retry-" }); + yield* (yield* makeTestState(root)).save(initial); + const { store, firstFailure, reported } = yield* listingWithReadFailures({ + root, + platform: "win32", + code: "EBUSY", + failures: 1, + }); + + const listing = yield* store.list.pipe(Effect.forkScoped); + yield* Deferred.await(firstFailure); + yield* TestClock.adjust("10 millis"); + expect((yield* Fiber.join(listing)).map(({ id }) => id)).toEqual([initial.id]); + expect(yield* Ref.get(reported)).toEqual([]); + }), + ), + ); + + it.effect("skips and reports a stack whose state stays unreadable after retries", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const [platform, code] of [ + ["win32", "EBUSY"], + ["linux", "EACCES"], + ] as const) { + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-skip-" }); + yield* (yield* makeTestState(root)).save(initial); + const { store, firstFailure, reported } = yield* listingWithReadFailures({ + root, + platform, + code, + failures: Number.POSITIVE_INFINITY, + }); + + const listing = yield* store.list.pipe(Effect.forkScoped); + yield* Deferred.await(firstFailure); + yield* TestClock.adjust("950 millis"); + expect(yield* Fiber.join(listing)).toEqual([]); + expect(yield* Ref.get(reported)).toEqual([initial.id]); + } + }), + ), + ); + + it.live.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "lists and claims readable stacks past a sibling directory it cannot access", + () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-eacces-" }); + const reported: Array = []; + const store = yield* Layer.build( + State.layer({ root, onInvalidState: (id) => Effect.sync(() => reported.push(id)) }), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + yield* store.save(initial); + const locked = path.join(root, "locked"); + yield* fs.makeDirectory(locked, { mode: 0o700 }); + yield* fs.writeFileString(path.join(locked, "state.json"), "{}"); + yield* Effect.acquireRelease(fs.chmod(locked, 0o000), () => + fs.chmod(locked, 0o700).pipe(Effect.orDie), + ); + + expect((yield* store.list).map(({ id }) => id)).toEqual([initial.id]); + expect(reported).toEqual(["locked"]); + expect((yield* store.claims).map(({ id }) => id)).toEqual([initial.id]); + }), + ), + ); + + it.live("skips and reports malformed, mismatched, and non-file entries while listing", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-invalid-" }); + const reported: Array = []; + const store = yield* Layer.build( + State.layer({ + root, + onInvalidState: (id) => Effect.sync(() => reported.push(id)), + }), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + yield* store.save(initial); + yield* fs.makeDirectory(path.join(root, "malformed")); + yield* fs.writeFileString(path.join(root, "malformed", "state.json"), "{broken"); + yield* fs.makeDirectory(path.join(root, "mismatched")); + yield* fs.writeFileString( + path.join(root, "mismatched", "state.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(State.SavedStack))(initial), + ); + yield* fs.makeDirectory(path.join(root, "directory", "state.json"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "stray-file"), ""); + + expect((yield* store.list).map(({ id }) => id)).toEqual([initial.id]); + // Windows resolves a path below a regular file as missing rather than unreadable. + expect(reported.toSorted()).toEqual( + process.platform === "win32" + ? ["directory", "malformed", "mismatched"] + : ["directory", "malformed", "mismatched", "stray-file"], + ); + }), + ), + ); + it.effect("cleans up a cancelled Windows replacement and releases the state lock", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/State.ts b/packages/stack/src/State.ts index 5f941e6edf..d2c9c955be 100644 --- a/packages/stack/src/State.ts +++ b/packages/stack/src/State.ts @@ -58,6 +58,13 @@ export const StackCredentials = Schema.Struct({ }); export interface StackCredentials extends Schema.Schema.Type {} +const PortClaim = Schema.Struct({ + key: Schema.String, + host: Schema.String, + port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })), +}); +export interface PortClaim extends Schema.Schema.Type {} + export const SavedStack = Schema.Struct({ id: SafeId, identity: Schema.Struct({ @@ -69,16 +76,17 @@ export const SavedStack = Schema.Struct({ instances: Schema.Array(SavedInstance), composition: Schema.Unknown, credentials: Schema.optionalKey(StackCredentials), - ports: Schema.Array( - Schema.Struct({ - key: Schema.String, - host: Schema.String, - port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })), - }), - ), + ports: Schema.Array(PortClaim), }); export interface SavedStack extends Schema.Schema.Type {} +const ClaimsDocument = Schema.Struct({ ports: Schema.Array(PortClaim) }); + +export interface StackClaims { + readonly id: string; + readonly ports: ReadonlyArray; +} + export class StateError extends Data.TaggedError("StateError")<{ readonly operation: string; readonly message: string; @@ -87,7 +95,10 @@ export class StateError extends Data.TaggedError("StateError")<{ export interface Interface { readonly read: (id: string) => Effect.Effect; + /** Skips each stack entry that stays unreadable after transient retries, reporting it to `onInvalidState`. */ readonly list: Effect.Effect, StateError>; + /** Decodes only each stack's port claims and silently skips entries that cannot provide them. */ + readonly claims: Effect.Effect, StateError>; readonly save: (state: SavedStack) => Effect.Effect; readonly remove: (id: string) => Effect.Effect; /** Not reentrant; wrap metadata updates here, while Ports operations acquire this lock themselves. */ @@ -156,25 +167,29 @@ const makeState = ( ), Schedule.upTo({ times: 12 }), ); - const renameErrorCode = (error: unknown): string | undefined => { + const errorCode = (error: unknown): string | undefined => { if (!Predicate.hasProperty(error, "cause")) return undefined; return Predicate.hasProperty(error.cause, "code") && typeof error.cause.code === "string" ? error.cause.code : undefined; }; + /** Windows reports a file that another process is replacing as a transient sharing violation. */ + const sharingViolation = (error: unknown) => + (options.platform ?? process.platform) === "win32" && + ["EPERM", "EACCES", "EBUSY"].includes(errorCode(error) ?? ""); + const retryTransientRead = (effect: Effect.Effect) => + effect.pipe( + Effect.retry({ schedule: publishRetrySchedule, while: sharingViolation }), + Effect.mapError((cause) => stateError("read", cause)), + ); const publish = Effect.fn("State.publish")(function* (temporary: string, target: string) { yield* fs.rename(temporary, target).pipe( - Effect.retry({ - schedule: publishRetrySchedule, - while: (error) => - (options.platform ?? process.platform) === "win32" && - ["EPERM", "EACCES", "EBUSY"].includes(renameErrorCode(error) ?? ""), - }), + Effect.retry({ schedule: publishRetrySchedule, while: sharingViolation }), Effect.mapError( (cause) => new StateError({ operation: "publish", - message: `Unable to publish state to ${target}${renameErrorCode(cause) ? ` (${renameErrorCode(cause)})` : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, + message: `Unable to publish state to ${target}${errorCode(cause) ? ` (${errorCode(cause)})` : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, cause, }), ), @@ -198,39 +213,46 @@ const makeState = ( const read = Effect.fn("State.read")(function* (id: string) { yield* checkId(id); const target = statePath(id); - const exists = yield* fs - .exists(target) - .pipe(Effect.mapError((cause) => stateError("read", cause))); + const exists = yield* fs.exists(target).pipe(retryTransientRead); if (!exists) return undefined; - const text = yield* fs - .readFileString(target) - .pipe(Effect.mapError((cause) => stateError("read", cause))); + const text = yield* fs.readFileString(target).pipe(retryTransientRead); const state = yield* decodeState(text, id, target); if (state.id !== id) { return yield* stateError("identity", "State document identity does not match its path"); } return state; }); - const list = Effect.fn("State.list")(function* () { - const entries = yield* fs - .readDirectory(root) - .pipe(Effect.mapError((cause) => stateError("list", cause))); - const states: Array = []; - for (const entry of entries) { - if (!Schema.is(SafeId)(entry)) continue; - const id = entry; - const value = yield* read(id).pipe( - Effect.catch((error) => - options.onInvalidState !== undefined && - (error.operation === "decode" || error.operation === "identity") - ? options.onInvalidState(id, error).pipe(Effect.as(undefined)) - : Effect.fail(error), - ), - ); - if (value !== undefined) states.push(value); - } - return states; - }); + const stackIds = fs.readDirectory(root).pipe( + Effect.map((entries) => entries.filter(Schema.is(SafeId))), + Effect.mapError((cause) => stateError("list", cause)), + ); + const readEntries = ( + readEntry: (id: string) => Effect.Effect, + onSkipped: (id: string, error: StateError) => Effect.Effect, + ) => + Effect.gen(function* () { + const entries: Array = []; + for (const id of yield* stackIds) { + const value = yield* readEntry(id).pipe( + Effect.catch((error) => onSkipped(id, error).pipe(Effect.as(undefined))), + ); + if (value !== undefined) entries.push(value); + } + return entries; + }); + const list = Effect.fn("State.list")(() => + readEntries(read, (id, error) => options.onInvalidState?.(id, error) ?? Effect.void), + ); + const decodeClaims = Schema.decodeEffect(Schema.fromJsonString(ClaimsDocument)); + const readClaims = (id: string) => + fs.readFileString(statePath(id)).pipe( + retryTransientRead, + Effect.flatMap((text) => + decodeClaims(text).pipe(Effect.mapError((cause) => stateError("decode", cause))), + ), + Effect.map(({ ports }): StackClaims => ({ id, ports })), + ); + const claims = Effect.fn("State.claims")(() => readEntries(readClaims, () => Effect.void)); const save = Effect.fn("State.save")(function* (state: SavedStack) { yield* checkId(state.id); const target = statePath(state.id); @@ -308,7 +330,7 @@ const makeState = ( }), ), ); - return { read, list: list(), save, remove, withLock }; + return { read, list: list(), claims: claims(), save, remove, withLock }; }); export const layer = (options: Options) => diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 92bb264c2c..7f4eba75c5 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Schema } from import { tmpdir } from "node:os"; import { create, discover, open, type DatabaseInstance, type ServiceInstance } from "./effect.ts"; import { initialization, postgres } from "./Commands.ts"; +import * as PromiseApi from "./index.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); @@ -55,6 +56,41 @@ it.live("registers and discovers saved definitions without inventing live observ }).pipe(Effect.scoped, Effect.provide(layer)), ); +it.live("discovers readable stacks past invalid siblings and reports each skipped entry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-invalid-" }); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* fs.makeDirectory(`${stateRoot}/malformed`); + yield* fs.writeFileString(`${stateRoot}/malformed/state.json`, "{broken"); + yield* fs.makeDirectory(`${stateRoot}/unreadable/state.json`, { recursive: true }); + + const silent = yield* discover({ stateRoot }); + expect(silent.map(({ definition }) => definition.id)).toEqual([stack.id]); + + const reported: Array = []; + const observed = yield* discover({ + stateRoot, + onInvalidState: (id) => Effect.sync(() => reported.push(id)), + }); + expect(observed.map(({ definition }) => definition.id)).toEqual([stack.id]); + expect(reported.toSorted()).toEqual(["malformed", "unreadable"]); + + const promiseReported: Array = []; + const promiseObserved = yield* Effect.promise(() => + PromiseApi.discover({ stateRoot, onInvalidState: (id) => promiseReported.push(id) }), + ); + expect(promiseObserved.map(({ definition }) => definition.id)).toEqual([stack.id]); + expect(promiseReported.toSorted()).toEqual(["malformed", "unreadable"]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + it.live("starts the owner on opt-in reopen without starting saved services", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index d626dfc68b..d107204cec 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -707,7 +707,7 @@ export const open = Effect.fn("Stack.open")( Effect.mapError((cause) => failure("open", cause)), ); -/** Lists saved resources separately from the availability of their live owners. */ +/** Lists readable saved stacks with their live owners; `onInvalidState` observes skipped entries. */ export const discover = Effect.fn("Stack.discover")( function* ( options: Pick & { diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 476c049901..24cb83f647 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -380,6 +380,19 @@ export const create = ( /** Opens an existing stack without launching its services. */ export const open = (options: StackEffect.OpenOptions, callOptions?: CallOptions): Promise => acquire(StackEffect.open(options), callOptions); -/** Discovers saved stacks and separately reports their live-owner availability. */ -export const discover = (options: Pick) => - Effect.runPromise(StackEffect.discover(options).pipe(Effect.provide(clientLayer))); +/** Discovers readable saved stacks with their live owners; `onInvalidState` observes skipped entries. */ +export const discover = ( + options: Pick & { + readonly onInvalidState?: (id: string, error: Error) => void; + }, +) => { + const onInvalidState = options.onInvalidState; + return Effect.runPromise( + StackEffect.discover({ + stateRoot: options.stateRoot, + ...(onInvalidState === undefined + ? {} + : { onInvalidState: (id, error) => Effect.sync(() => onInvalidState(id, error)) }), + }).pipe(Effect.provide(clientLayer)), + ); +}; diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 8658a899d1..d2fc743df5 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -8,11 +8,13 @@ import { Effect, Exit, Fiber, + Layer, Option, Ref, Sink, Stream, } from "effect"; +import { TestClock } from "effect/testing"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import { HttpClient } from "effect/unstable/http"; @@ -732,6 +734,47 @@ describe("container process adapter", () => { expect(yield* Ref.get(present)).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("bounds a hung docker create and removes the container it may still create", () => + Effect.gen(function* () { + const engine = yield* makeHangingCreateSpawner(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provide(engine.layer), + ); + const launch = yield* Effect.scoped( + runtime.launch({ image, stackId: "i".repeat(64), instanceId: "hung-create", env: {} }), + ).pipe(Effect.provide(engine.layer), Effect.exit, Effect.forkChild); + yield* Deferred.await(engine.createStarted); + yield* TestClock.adjust("2 minutes"); + const result = yield* Fiber.join(launch); + expect(Exit.isFailure(result)).toBe(true); + const failure = Exit.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + expect(failure instanceof ContainerLaunchError).toBe(true); + expect( + failure instanceof ContainerLaunchError ? failure.failure.message : undefined, + ).toContain("did not respond"); + expect(engine.commands).toContainEqual(["rm", "--force", engine.createdName()]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("removes the container a hung docker create may still create when interrupted", () => + Effect.gen(function* () { + const engine = yield* makeHangingCreateSpawner(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provide(engine.layer), + ); + const launch = yield* Effect.scoped( + runtime.launch({ image, stackId: "i".repeat(64), instanceId: "interrupted", env: {} }), + ).pipe(Effect.provide(engine.layer), Effect.forkChild); + yield* Deferred.await(engine.createStarted); + yield* Fiber.interrupt(launch); + expect(Exit.hasInterrupts(yield* Fiber.await(launch))).toBe(true); + expect(engine.createdName()).toMatch(/^supabase-/u); + expect(engine.commands).toContainEqual(["rm", "--force", engine.createdName()]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const repoDigest = (spawner: ChildProcessSpawnerService["Service"], image: string) => @@ -1060,6 +1103,44 @@ const successfulHandle = () => unref: Effect.succeed(Effect.void), }); +const makeHangingCreateSpawner = Effect.fn("ContainerTest.hangingCreateSpawner")(function* () { + const commands: string[][] = []; + const createStarted = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) return Effect.die("unexpected piped command"); + commands.push([...command.args]); + if (command.args[0] !== "create") return Effect.succeed(successfulHandle()); + // The engine never answers create; the real daemon may or may not have committed it. + return Deferred.succeed(createStarted, undefined).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), + ); + }); + const createdName = () => { + const args = commands.find((command) => command[0] === "create") ?? []; + return args[args.indexOf("--name") + 1]; + }; + return { + commands, + createStarted, + createdName, + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + }; +}); + const ready = (process: ContainerProcess) => Effect.gen(function* () { const signal = yield* Deferred.make(); diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index eb08ccad72..441bdde76e 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -109,6 +109,9 @@ const PULL_MAX_RETRIES = 4; const pullBackoff = Schedule.exponential("2 seconds").pipe(Schedule.jittered); +/** `docker create` only writes metadata; a healthy daemon answers well within this bound. */ +const CREATE_TIMEOUT: Duration.Input = "2 minutes"; + const PublishedPorts = Schema.Record( Schema.String, Schema.NullOr( @@ -330,18 +333,40 @@ export const makeContainerRuntime = (options: { return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - // Creation must settle before cleanup can safely run. - const creation = yield* run(args, { timeout: undefined }).pipe( - Effect.mapError( - (error) => - new ContainerError({ - operation: error.operation, - message: `${error.message} (container name ${name})`, - cause: error, - }), + // Creation must settle before cleanup can safely run. The timeout below needs genuine + // interruptibility to bound a hung daemon, so this restores it just for the create + // call; either that timeout or an external interrupt reaching this window may still + // leave a container needing best-effort removal, handled in both branches below. + const creation = yield* restore( + run(args, { timeout: undefined }).pipe( + Effect.timeout(CREATE_TIMEOUT), + Effect.mapError((error) => + error._tag === "TimeoutError" + ? error + : new ContainerError({ + operation: error.operation, + message: `${error.message} (container name ${name})`, + cause: error, + }), + ), + Effect.catchTag("TimeoutError", () => + Effect.uninterruptible( + Effect.gen(function* () { + yield* run(["rm", "--force", name], { timeout: "10 seconds" }).pipe( + Effect.ignore, + ); + return yield* errorFor( + "create", + `Engine did not respond to container creation within ${CREATE_TIMEOUT} (container name ${name})`, + ); + }), + ), + ), + Effect.onInterrupt(() => + run(["rm", "--force", name], { timeout: "10 seconds" }).pipe(Effect.ignore), + ), ), - Effect.exit, - ); + ).pipe(Effect.exit); const stopped = yield* Ref.make(false); const removed = yield* Ref.make(false); const reconcileAbsent = Effect.fn("Container.reconcileAbsent")(function* ( diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 62222d3398..2b3ffce13e 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -138,11 +138,20 @@ export const ensureInternalDatabase = Effect.fn("PostgresDatabaseSession.ensureI openInternal: Effect.Effect, ) { return yield* Effect.gen(function* () { - const databases = yield* postgres.query("SELECT 1 FROM pg_database WHERE datname = $1", [ - INTERNAL_DATABASE, - ]); - if (databases.length === 0) - yield* postgres.execute(`CREATE DATABASE ${INTERNAL_DATABASE} WITH OWNER postgres`); + const exists = postgres + .query("SELECT 1 FROM pg_database WHERE datname = $1", [INTERNAL_DATABASE]) + .pipe(Effect.map((databases) => databases.length > 0)); + // A concurrent creator, such as an abandoned earlier attempt, can win between check and create. + if (!(yield* exists)) + yield* postgres + .execute(`CREATE DATABASE ${INTERNAL_DATABASE} WITH OWNER postgres`) + .pipe( + Effect.catch((error) => + exists.pipe( + Effect.flatMap((created) => (created ? Effect.void : Effect.fail(error))), + ), + ), + ); const internal = yield* openInternal; for (const schema of INTERNAL_SCHEMAS) { diff --git a/packages/stack/src/runtime/postgres-database-session.integration.test.ts b/packages/stack/src/runtime/postgres-database-session.integration.test.ts index 2b41980f38..1a4de7c5b4 100644 --- a/packages/stack/src/runtime/postgres-database-session.integration.test.ts +++ b/packages/stack/src/runtime/postgres-database-session.integration.test.ts @@ -147,6 +147,72 @@ describe("Postgres database session", () => { }), ); + it.live("accepts an internal database that a concurrent creator publishes first", () => + Effect.gen(function* () { + const calls: Array = []; + let published = false; + const postgres = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + Effect.suspend(() => { + calls.push(sql); + if (sql.startsWith("CREATE DATABASE")) { + published = true; + return Effect.fail( + new SqlError({ + reason: new UnknownError({ + message: 'database "_supabase" already exists', + cause: new Error("duplicate database"), + }), + }), + ); + } + return Effect.succeed( + sql.startsWith("SELECT 1 FROM pg_database") && published ? [{ exists: true }] : [], + ); + }), + withTransaction: (effect: Effect.Effect) => effect, + }); + const internal = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + Effect.sync(() => { + calls.push(sql); + return []; + }), + withTransaction: (effect: Effect.Effect) => effect, + }); + + yield* Effect.scoped(ensureInternalDatabase(postgres, Effect.succeed(internal))); + + expect(calls).toContain("CREATE DATABASE _supabase WITH OWNER postgres"); + expect(calls).toContain("CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION postgres"); + }), + ); + + it.live("fails when the internal database is still missing after a failed create", () => + Effect.gen(function* () { + const postgres = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + sql.startsWith("CREATE DATABASE") + ? Effect.fail( + new SqlError({ + reason: new UnknownError({ + message: "permission denied", + cause: new Error("permission denied"), + }), + }), + ) + : Effect.succeed([]), + withTransaction: (effect: Effect.Effect) => effect, + }); + + const error = yield* Effect.scoped( + ensureInternalDatabase(postgres, Effect.die("internal database must not open")), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(DatabaseBootstrapError); + }), + ); + it.live("reuses an existing internal database without recreating it", () => Effect.gen(function* () { const calls: Array = []; diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 4ebaa5e33e..9abe6fd8d0 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -261,6 +261,7 @@ const reconcileContainerPassword = Effect.fn("Database.reconcileContainerPasswor ), ); +/** Idempotent readiness reconciliation, so a session also re-runs it as its probe. */ const health = Effect.fn("Database.health")(( endpoint: BackendEndpoint, config: DatabaseConfig, @@ -930,19 +931,21 @@ export const makeDatabase = ( yield* Ref.set(endpoint, selectedEndpoint); const stderrTail = yield* Ref.make(""); const stderrDrained = yield* publishLogs(process, logs, context.scope, stderrTail); + const setup = health(selectedEndpoint, config, Effect.void, { + fs, + instanceRoot, + version: config.version, + runtime: options.runtime, + markInitialized: + storage === undefined + ? undefined + : storage + .markInitialized(config.version) + .pipe(Effect.mapError((cause) => errorFor("health", cause))), + }); return { - health: health(selectedEndpoint, config, Effect.void, { - fs, - instanceRoot, - version: config.version, - runtime: options.runtime, - markInitialized: - storage === undefined - ? undefined - : storage - .markInitialized(config.version) - .pipe(Effect.mapError((cause) => errorFor("health", cause))), - }), + health: setup, + probe: setup, exit: processExit(process.exitCode, { tail: stderrTail, drained: stderrDrained }), stop: process.kill.pipe(Effect.mapError((cause) => errorFor("stop", cause))), remove: fs.remove(socketPath, { recursive: true, force: true }).pipe( @@ -959,30 +962,32 @@ export const makeDatabase = ( yield* Ref.set(endpoint, selectedEndpoint); yield* publishLogs(launched, logs, context.scope); const session = runtimeFromContainer(launched, config.stopGraceSeconds === 0); + const setup = health( + selectedEndpoint, + config, + reconcileContainerPassword( + options.runtime, + launched.id, + config.databasePassword, + spawner, + ), + { + fs, + instanceRoot, + version: config.version, + runtime: options.runtime, + markInitialized: + storage === undefined + ? undefined + : storage + .markInitialized(config.version) + .pipe(Effect.mapError((cause) => errorFor("health", cause))), + }, + ); return { ...session, - health: health( - selectedEndpoint, - config, - reconcileContainerPassword( - options.runtime, - launched.id, - config.databasePassword, - spawner, - ), - { - fs, - instanceRoot, - version: config.version, - runtime: options.runtime, - markInitialized: - storage === undefined - ? undefined - : storage - .markInitialized(config.version) - .pipe(Effect.mapError((cause) => errorFor("health", cause))), - }, - ), + health: setup, + probe: setup, remove: session.remove.pipe(Effect.tap(() => Ref.set(endpoint, undefined))), } satisfies RuntimeSession; }), diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index 03348ea9e4..c4f22115f5 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -463,7 +463,14 @@ const realtimeService = Effect.fn(function* (container: ContainerRuntime) { const platform = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); -const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { +const nativeFixtureArtifact = Effect.fn(function* ( + cacheRoot: string, + artifact: { + readonly name: string; + readonly executablePath: string; + readonly files: Readonly>; + }, +) { const platformName = `${process.platform}-${process.arch}`; const target = platformName === "darwin-arm64" @@ -476,43 +483,25 @@ const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { if (target === undefined) return yield* Effect.fail(`Unsupported test platform: ${platformName}`); const request: ArtifactRequest = { - key: `slim-services/pooler/v2.9.12/${target}`, - requiredRuntimePaths: ["bin/server", "bin/prepare", "bin/provision-tenant"], - executablePath: "bin/server", + key: `slim-services/${artifact.name}/${target}`, + requiredRuntimePaths: Object.keys(artifact.files), + executablePath: artifact.executablePath, }; const fs = yield* FileSystem.FileSystem; - const server = - `#!${process.execPath}\nconst http = require("node:http");\n` + - `const port = Number(process.env.PORT);\n` + - `if (process.env.TENANT_ID === "unrelated-failure") { console.error("unrelated startup failure"); process.exit(1); }\n` + - `if (process.env.TENANT_ID === "retry-exhaustion") {\n` + - `const blocker = http.createServer();\n` + - `blocker.listen(port, "127.0.0.1", () => {\n` + - `const failed = http.createServer();\n` + - `failed.on("error", () => { console.error("port already in use " + "x".repeat(2000)); process.exit(1); });\n` + - `failed.listen(port, "127.0.0.1");\n` + - `});\nreturn;\n}\n` + - `const server = http.createServer((_request, response) => response.end("owned-fixture:" + port));\n` + - `server.on("error", (error) => { console.error(error); process.exit(1); });\n` + - `server.listen(port, "127.0.0.1", () => {\n` + - `console.log("Running SupavisorWeb.Endpoint at 127.0.0.1:" + port + " (http)");\n` + - `});\n`; - const oneShot = `#!${process.execPath}\nprocess.exit(0);\n`; const source: ArtifactSource = { checksum: () => Effect.succeed("0".repeat(64)), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); - yield* fs.writeFileString(`${destination}/bin/server`, server); - yield* fs.writeFileString(`${destination}/bin/prepare`, oneShot); - yield* fs.writeFileString(`${destination}/bin/provision-tenant`, oneShot); - yield* fs.chmod(`${destination}/bin/prepare`, 0o755); - yield* fs.chmod(`${destination}/bin/provision-tenant`, 0o755); + for (const [file, content] of Object.entries(artifact.files)) { + yield* fs.writeFileString(`${destination}/${file}`, content); + yield* fs.chmod(`${destination}/${file}`, 0o755); + } }).pipe( Effect.mapError( (cause) => new PreparationError({ - message: `Unable to write native Pooler fixture: ${cause.message}`, + message: `Unable to write native ${artifact.name} fixture: ${cause.message}`, cause, }), ), @@ -522,6 +511,31 @@ const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { yield* store.prepare(request); }); +const nativePoolerArtifact = (cacheRoot: string) => { + const server = + `#!${process.execPath}\nconst http = require("node:http");\n` + + `const port = Number(process.env.PORT);\n` + + `if (process.env.TENANT_ID === "unrelated-failure") { console.error("unrelated startup failure"); process.exit(1); }\n` + + `if (process.env.TENANT_ID === "retry-exhaustion") {\n` + + `const blocker = http.createServer();\n` + + `blocker.listen(port, "127.0.0.1", () => {\n` + + `const failed = http.createServer();\n` + + `failed.on("error", () => { console.error("port already in use " + "x".repeat(2000)); process.exit(1); });\n` + + `failed.listen(port, "127.0.0.1");\n` + + `});\nreturn;\n}\n` + + `const server = http.createServer((_request, response) => response.end("owned-fixture:" + port));\n` + + `server.on("error", (error) => { console.error(error); process.exit(1); });\n` + + `server.listen(port, "127.0.0.1", () => {\n` + + `console.log("Running SupavisorWeb.Endpoint at 127.0.0.1:" + port + " (http)");\n` + + `});\n`; + const oneShot = `#!${process.execPath}\nprocess.exit(0);\n`; + return nativeFixtureArtifact(cacheRoot, { + name: "pooler/v2.9.12", + executablePath: "bin/server", + files: { "bin/server": server, "bin/prepare": oneShot, "bin/provision-tenant": oneShot }, + }); +}; + const NativeLaunchPayload = Schema.Struct({ executable: Schema.String, args: Schema.optionalKey(Schema.Array(Schema.String)), @@ -579,6 +593,69 @@ const countingSpawner = ( ), }); +const interceptRestLaunch = ( + spawner: ChildProcessSpawnerService["Service"], + onLaunch: (payload: typeof NativeLaunchPayload.Type) => Effect.Effect, +): ChildProcessSpawnerService["Service"] => ({ + ...spawner, + spawn: (command) => + spawner.spawn(command).pipe( + Effect.map((handle) => ({ + ...handle, + getInputFd: (fd: number) => { + const sink = handle.getInputFd(fd); + if (fd !== 4) return sink; + return Sink.mapInputEffect(sink, (bytes) => + Effect.gen(function* () { + const payload = yield* Schema.decodeEffect( + Schema.fromJsonString(NativeLaunchPayload), + )(new TextDecoder().decode(bytes)).pipe(Effect.orDie); + if (payload.executable.endsWith("/bin/postgrest")) yield* onLaunch(payload); + return bytes; + }), + ); + }, + })), + ), +}); + +const nativeRestRecipe = Effect.fn(function* ( + root: string, + program: string, + spawner: ChildProcessSpawnerService["Service"], + env: Readonly> = {}, + nativeReadinessOutput?: ProcessRecipeSpec["nativeReadinessOutput"], +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const client = yield* HttpClient.HttpClient; + const cacheRoot = path.join(root, "cache"); + yield* nativeFixtureArtifact(cacheRoot, { + name: "postgrest/v16.2", + executablePath: "bin/postgrest", + files: { "bin/postgrest": `#!${process.execPath}\n${program}` }, + }); + return yield* makeProcessRecipe( + creation, + { + ...options, + root, + cacheRoot, + runtime: "native", + platform: { os: process.platform, arch: process.arch }, + }, + { fs, path, crypto, client, spawner, container: undefined }, + { + ...spec, + env: (_creation, endpoints) => + Effect.succeed({ ...env, PORT: String(endpoints.get("http")?.port) }), + startupCommands: [], + ...(nativeReadinessOutput === undefined ? {} : { nativeReadinessOutput }), + }, + ); +}); + describe("process recipe startup", () => { it.effect("reports the startup process's recent stdout and stderr when it exits non-zero", () => Effect.scoped( @@ -605,6 +682,154 @@ describe("process recipe startup", () => { ).pipe(Effect.provide(platform)), ); + it.live("relaunches on fresh ports while a silently failing process finds its ports taken", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const testScope = yield* Effect.scope; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-collision-" }); + const launches = yield* Ref.make(0); + const taken = yield* Ref.make>([]); + const collidingSpawner = interceptRestLaunch(spawner, (payload) => + Effect.gen(function* () { + if ((yield* Ref.updateAndGet(launches, (count) => count + 1)) > 2) return; + const port = Number(payload.env?.PORT); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = Net.createServer((socket) => socket.destroy()); + server.once("error", (cause) => resume(Effect.die(cause))); + server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe(Scope.provide(testScope)); + yield* Ref.update(taken, (ports) => [...ports, port]); + }), + ); + const recipe = yield* nativeRestRecipe( + root, + `const http = require("node:http");\n` + + `const server = http.createServer((_request, response) => response.end("owned-fixture"));\n` + + `server.on("error", () => process.exit(1));\n` + + `server.listen(Number(process.env.PORT), "127.0.0.1");\n`, + collidingSpawner, + ); + const service = yield* makeService(recipe.definition, { id: "rest", config: creation }); + + yield* service.start; + yield* service.ready; + + const endpoint = (yield* Ref.get(recipe.endpoints)).get("http"); + expect(yield* Ref.get(launches)).toBe(3); + expect(yield* Ref.get(taken)).toHaveLength(2); + expect(yield* Ref.get(taken)).not.toContain(endpoint?.port); + const response = yield* client.execute( + HttpClientRequest.get(`http://${endpoint?.host}:${endpoint?.port}/`), + ); + expect(yield* response.text).toBe("owned-fixture"); + yield* service.stop; + }), + ).pipe(Effect.provide(platform)), + ); + + it.live( + "does not relaunch a crash after the bind is confirmed while its port still answers", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const testScope = yield* Effect.scope; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-crash-" }); + const launches = yield* Ref.make(0); + const inheritedListener = interceptRestLaunch(spawner, (payload) => + Effect.gen(function* () { + yield* Ref.update(launches, (count) => count + 1); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = Net.createServer((socket) => socket.destroy()); + server.once("error", (cause) => resume(Effect.die(cause))); + server.listen(Number(payload.env?.PORT), "127.0.0.1", () => + resume(Effect.succeed(server)), + ); + }), + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe(Scope.provide(testScope)); + }), + ); + const recipe = yield* nativeRestRecipe( + root, + `process.stdout.write("listener bound\\n");\n` + + `setTimeout(() => process.exit(4), 50);\n`, + inheritedListener, + {}, + (line) => line.includes("listener bound"), + ); + const service = yield* makeService(recipe.definition, { id: "rest", config: creation }); + + yield* service.start; + const failure = yield* Effect.flip(service.ready); + + expect(failure.message).toContain("rest exited with 4 before it was ready"); + expect(failure.message).not.toContain("native port collision"); + expect(yield* Ref.get(launches)).toBe(1); + }), + ).pipe(Effect.provide(platform)), + ); + + it.live("fails an early exit promptly when a detached descendant keeps its output open", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-drain-" }); + const pidFile = path.join(root, "descendant.pid"); + yield* Effect.addFinalizer(() => + fs.readFileString(pidFile).pipe( + Effect.flatMap((pid) => + Effect.sync(() => { + try { + process.kill(Number(pid), "SIGKILL"); + } catch { + // The descendant already exited. + } + }), + ), + Effect.ignore, + ), + ); + const recipe = yield* nativeRestRecipe( + root, + `const { spawn } = require("node:child_process");\n` + + `const { writeFileSync, writeSync } = require("node:fs");\n` + + `const descendant = spawn(process.execPath, ["-e", "setTimeout(() => {}, 300000)"], { detached: true, stdio: ["ignore", "inherit", "inherit"] });\n` + + `writeFileSync(process.env.PID_FILE, String(descendant.pid));\n` + + `writeSync(2, "startup failed before listening\\n");\n` + + `process.exit(3);\n`, + spawner, + { PID_FILE: pidFile }, + ); + const service = yield* makeService(recipe.definition, { id: "rest", config: creation }); + + yield* service.start; + const failure = yield* Effect.flip(service.ready); + + expect(failure.message).toContain("rest exited with 3 before it was ready"); + expect(failure.message).toContain("startup failed before listening"); + expect(failure.message).not.toContain("native port collision"); + expect(yield* fs.exists(pidFile)).toBe(true); + }), + ).pipe(Effect.provide(platform)), + ); + it.live("recovers when a healthy competing listener claims Pooler's selected HTTP port", () => Effect.scoped( Effect.gen(function* () { @@ -699,6 +924,7 @@ describe("process recipe startup", () => { config: creation, scope, }); + yield* runtime.health; const endpoints = yield* Ref.get(recipe.endpoints); const endpoint = endpoints.get("http"); const collided = yield* Ref.get(collisionPort); @@ -835,8 +1061,14 @@ describe("process recipe startup", () => { Stream.concat( Stream.fromEffectDrain( Deferred.await(exitReached).pipe( - Effect.andThen(TestClock.adjust("61 seconds")), - Effect.tap(() => Deferred.succeed(clockAdvanced, undefined)), + // The bounded output drain can close this attempt while the clock moves. + Effect.andThen( + Effect.uninterruptible( + TestClock.adjust("61 seconds").pipe( + Effect.andThen(Deferred.succeed(clockAdvanced, undefined)), + ), + ), + ), ), ), ), @@ -888,13 +1120,10 @@ describe("process recipe startup", () => { ); if (recipe.definition.prepare !== undefined) yield* recipe.definition.prepare(creation); const scope = yield* Scope.fork(yield* Effect.scope, "sequential"); - const launched = recipe.definition - .launch({ id: "pooler", config: creation, scope }) - .pipe(Effect.forkChild); - const fiber = yield* launched; + const runtime = yield* recipe.definition.launch({ id: "pooler", config: creation, scope }); + const health = yield* Effect.flip(runtime.health).pipe(Effect.forkChild); yield* Deferred.await(clockAdvanced); - const runtime = yield* Fiber.join(fiber); - const failure = yield* Effect.flip(runtime.health); + const failure = yield* Fiber.join(health); expect(failure.operation).toBe("launch"); expect(failure.message).toContain("native port collision"); expect(failure.message).not.toContain("readiness timed out"); @@ -907,7 +1136,7 @@ describe("process recipe startup", () => { ).pipe(Effect.provide(platform)), ); - it.live("does not retry an unrelated native Pooler startup failure", () => + it.live("does not relaunch a native process that exits early while its ports stay free", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -954,10 +1183,10 @@ describe("process recipe startup", () => { if (recipe.definition.prepare !== undefined) yield* recipe.definition.prepare(creation); const scope = yield* Scope.fork(yield* Effect.scope, "sequential"); const runtime = yield* recipe.definition.launch({ id: "pooler", config: creation, scope }); + const health = yield* Effect.exit(runtime.health); expect(yield* Ref.get(mainLaunches)).toBe(1); expect(yield* Ref.get(startupLaunches)).toEqual(["prepare", "provision-tenant"]); expect(yield* Ref.get(recipe.endpoints)).toEqual(new Map()); - const health = yield* Effect.exit(runtime.health); expect(Exit.isFailure(health)).toBe(true); if (Exit.isFailure(health)) expect(health.cause.toString()).toContain("unrelated startup failure"); diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 4802810ecb..58c5424ab7 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -2,11 +2,13 @@ import { Cause, Clock, Crypto, + Data, Deferred, Duration, Effect, Exit, FileSystem, + Option, Path, PubSub, Ref, @@ -19,6 +21,7 @@ import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/u import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as Net from "node:net"; import { prepareNativeArtifact, resolveArtifact, type ServiceKind } from "../Artifacts.ts"; +import { accepts } from "../Ports.ts"; import { type ContainerError, type ContainerProcess, @@ -263,6 +266,9 @@ const publishLogs = Effect.fn("ProcessRecipe.publishLogs")(( }); const startupTimeoutSeconds = 60; +const nativeLaunchAttempts = 3; +const probeTimeout = Duration.seconds(10); +const outputDrainGrace = Duration.seconds(2); const startupOutputTailLines = 20; const startupOutputLineChars = 1_000; @@ -325,16 +331,17 @@ const startupFailure = ( const isAddressInUse = (line: string) => /eaddrinuse|address already in use|port already in use/i.test(line); -const terminalSession = ( - error: ServiceError, - endpoints: Ref.Ref>, -) => - ({ - health: Effect.fail(error), - exit: Effect.succeed(Exit.fail(error)), - stop: Effect.void, - remove: Ref.set(endpoints, new Map()), - }) satisfies RuntimeSession; +class NativePortCollision extends Data.TaggedError("NativePortCollision")<{ + readonly failure: ServiceError; +}> {} + +/** Another process accepting on a port the exited attempt was given means that attempt lost the bind. */ +const anotherListenerHolds = (endpoints: ReadonlyMap) => + Effect.forEach( + [...endpoints.values()].filter((endpoint) => endpoint.kind === "tcp"), + (endpoint) => accepts(endpoint.host ?? "127.0.0.1", endpoint.port), + { concurrency: "unbounded" }, + ).pipe(Effect.map((accepted) => accepted.some(Boolean))); const collectNativeOutput = Effect.fn("ProcessRecipe.collectNativeOutput")(function* ( process: NativeProcess, @@ -570,37 +577,19 @@ export const makeProcessRecipe = } const deadline = (yield* Clock.currentTimeMillis) + startupTimeoutSeconds * 1_000; - const lastCollision = yield* Ref.make(undefined); - let firstAttempt = true; - const launchAttempt = Effect.fn("ProcessRecipe.launchNativeAttempt")(function* () { - if ( - spec.nativeReadinessOutput !== undefined && - (yield* Clock.currentTimeMillis) >= deadline - ) { - const previousCollision = yield* Ref.get(lastCollision); - return terminalSession( - previousCollision === undefined - ? serviceError("health", `${context.config.service} native readiness timed out`) - : serviceError("launch", previousCollision.message), - endpoints, - ); - } - const attemptScope = yield* Scope.fork(context.scope, "sequential"); - const heldReservation = firstAttempt ? reusableReservation : undefined; - const reuse = firstAttempt ? heldReservation?.endpoints : undefined; - firstAttempt = false; - const reservation = - reuse === undefined ? yield* reserveEndpoints(attemptScope) : undefined; - const selected = reuse ?? reservation?.endpoints ?? new Map(); + const spawnAttempt = Effect.fn("ProcessRecipe.spawnNativeAttempt")(function* (held?: { + readonly portScope: Scope.Closeable; + readonly endpoints: ReadonlyMap; + }) { + const scope = yield* Scope.fork(context.scope, "sequential"); + const reservation = held ?? (yield* reserveEndpoints(scope)); + const selected = reservation.endpoints; const args = yield* spec.args(context.config, selected, { container: false, artifactRoot, }); const env = yield* spec.env(context.config, selected, false); - if (reservation !== undefined) yield* Scope.close(reservation.portScope, Exit.void); - if (heldReservation !== undefined) - yield* Scope.close(heldReservation.portScope, Exit.void); - + yield* Scope.close(reservation.portScope, Exit.void); const native: NativeProcess = yield* spawnNativeProcess( { executable, @@ -612,140 +601,156 @@ export const makeProcessRecipe = defaultNativeProcessLauncher(), { stackId: String(options.stackId), workloadId: context.id }, ).pipe( - Scope.provide(attemptScope), + Scope.provide(scope), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, deps.spawner), Effect.mapError((cause) => serviceError("launch", cause)), ); - if (spec.nativeReadinessOutput === undefined) { - yield* Ref.set(endpoints, selected); - yield* publishLogs(native, logs, attemptScope); - const ready = selected.get("http"); - return { - health: - ready === undefined - ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) - : readiness(deps.client, ready, spec.healthPath), - exit: processExit(native.exitCode), - stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: Ref.set(endpoints, new Map()), - } satisfies RuntimeSession; - } - const output = yield* collectNativeOutput( native, logs, selected, spec.nativeReadinessOutput, - attemptScope, + scope, ); - const ready = selected.get("http"); - const timeLeft = Math.max(0, deadline - (yield* Clock.currentTimeMillis)); - const readyCondition = - ready === undefined - ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) - : Effect.all( - [ - readiness(deps.client, ready, spec.healthPath, Duration.millis(timeLeft)), - Deferred.await(output.bindReady), - ], - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.asVoid); - const boundedReady = readyCondition.pipe( - Effect.timeout(Duration.millis(timeLeft)), + yield* Ref.set(endpoints, selected); + return { native, output, selected, scope }; + }); + type NativeAttempt = Effect.Success>; + + const readyCondition = (attempt: NativeAttempt, timeout: Duration.Input) => { + const http = attempt.selected.get("http"); + if (http === undefined) + return Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")); + return Effect.all( + [ + readiness(deps.client, http, spec.healthPath, timeout), + spec.nativeReadinessOutput === undefined + ? Effect.void + : Deferred.await(attempt.output.bindReady), + ], + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.timeout(timeout), Effect.mapError((cause) => serviceError("health", cause)), ); + }; + const recentOutput = (attempt: NativeAttempt) => + Effect.all({ + stdout: Ref.get(attempt.output.stdout), + stderr: Ref.get(attempt.output.stderr), + }); + + const firstAttempt = yield* spawnAttempt(reusableReservation); + const live = yield* Ref.make(firstAttempt); + const unobserved = yield* Ref.make(firstAttempt); + const settled = yield* Deferred.make>(); + const settleOnExit = (attempt: NativeAttempt) => + Effect.forkIn( + processExit(attempt.native.exitCode).pipe( + Effect.flatMap((exit) => Deferred.succeed(settled, exit)), + ), + context.scope, + ); + const settleFailure = (failure: ServiceError) => + Deferred.succeed(settled, Exit.fail(failure)).pipe(Effect.andThen(Effect.fail(failure))); + + const nextAttempt = Ref.getAndSet(unobserved, undefined).pipe( + Effect.flatMap((attempt) => + attempt !== undefined + ? Effect.succeed(attempt) + : spawnAttempt().pipe( + Effect.tap((relaunched) => Ref.set(live, relaunched)), + Effect.catch(settleFailure), + ), + ), + ); + + const observeAttempt = Effect.fn("ProcessRecipe.observeNativeAttempt")(function* ( + attempt: NativeAttempt, + ) { + const timeLeft = Math.max(0, deadline - (yield* Clock.currentTimeMillis)); const observed = yield* Effect.race( - Effect.exit(boundedReady).pipe( + Effect.exit(readyCondition(attempt, Duration.millis(timeLeft))).pipe( Effect.map((result) => ({ _tag: "ready" as const, result })), ), - Effect.exit(native.exitCode).pipe( + Effect.exit(attempt.native.exitCode).pipe( Effect.map((result) => ({ _tag: "exit" as const, result })), ), ); - if (observed._tag === "ready" && Exit.isSuccess(observed.result)) { - yield* Ref.set(endpoints, selected); - return { - health: Effect.void, - exit: processExit(native.exitCode), - stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: Ref.set(endpoints, new Map()), - } satisfies RuntimeSession; - } - if (observed._tag === "ready") { - const bindReady = yield* Deferred.isDone(output.bindReady); - const [stdoutLines, stderrLines] = yield* Effect.all([ - Ref.get(output.stdout), - Ref.get(output.stderr), - ]); - const failure = serviceError( + yield* settleOnExit(attempt); + if (Exit.isSuccess(observed.result)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(observed.result.cause)); + if (error !== undefined && !Cause.isTimeoutError(error.cause)) return yield* error; + const bindConfirmed = + spec.nativeReadinessOutput === undefined || + (yield* Deferred.isDone(attempt.output.bindReady)); + return yield* serviceError( "health", withRecentOutput( - bindReady + bindConfirmed ? `${context.config.service} HTTP readiness timed out` : `${context.config.service} native listener bind was not confirmed`, - { stdout: stdoutLines, stderr: stderrLines }, + yield* recentOutput(attempt), ), ); - yield* Ref.set(endpoints, selected); - return { - health: Effect.fail(failure), - exit: processExit(native.exitCode), - stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: Ref.set(endpoints, new Map()), - } satisfies RuntimeSession; } - if (Exit.isFailure(observed.result)) yield* Scope.close(attemptScope, Exit.void); - else yield* Deferred.await(output.drained); - const [stdoutLines, stderrLines] = yield* Effect.all([ - Ref.get(output.stdout), - Ref.get(output.stderr), - ]); + // A detached descendant can hold the output pipes open after the process exits. + if (Exit.isSuccess(observed.result)) + yield* Deferred.await(attempt.output.drained).pipe( + Effect.timeoutOption(outputDrainGrace), + ); + yield* Scope.close(attempt.scope, Exit.void); + yield* Ref.set(endpoints, new Map()); const exitMessage = Exit.isSuccess(observed.result) - ? `${context.config.service} startup exited with ${observed.result.value}` - : `${context.config.service} startup process failed: ${Cause.pretty(observed.result.cause)}`; + ? `${context.config.service} exited with ${observed.result.value} before it was ready` + : `${context.config.service} process failed: ${Cause.pretty(observed.result.cause)}`; const failure = serviceError( "launch", - withRecentOutput(exitMessage, { stdout: stdoutLines, stderr: stderrLines }), + withRecentOutput(exitMessage, yield* recentOutput(attempt)), ); - const collision = yield* Ref.get(output.bindError); - const nonzeroExit = Exit.isSuccess(observed.result) && observed.result.value !== 0; - if (Exit.isSuccess(observed.result)) yield* Scope.close(attemptScope, Exit.void); - yield* Ref.set(endpoints, new Map()); - if (collision && nonzeroExit) { - const collisionFailure = serviceError( - "native-port-collision", + const collided = + Exit.isSuccess(observed.result) && + observed.result.value !== 0 && + ((yield* Ref.get(attempt.output.bindError)) || + (!(yield* Deferred.isDone(attempt.output.bindReady)) && + (yield* anotherListenerHolds(attempt.selected)))); + if (!collided) return yield* settleFailure(failure); + return yield* new NativePortCollision({ + failure: serviceError( + "launch", `${context.config.service} native port collision\n${failure.message}`, - ); - yield* Ref.set(lastCollision, collisionFailure); - return yield* collisionFailure; - } - return terminalSession(failure, endpoints); + ), + }); }); - if (spec.nativeReadinessOutput !== undefined) { - const collisionRetries = Schedule.recurs(2).pipe( - Schedule.setInputType(), - Schedule.while( - ({ input }) => - input instanceof ServiceError && input.operation === "native-port-collision", - ), - ); - return yield* launchAttempt().pipe( - Effect.retry(collisionRetries), - Effect.catchIf( - (error) => - error instanceof ServiceError && error.operation === "native-port-collision", - (collision) => - Effect.succeed( - terminalSession(serviceError("launch", collision.message), endpoints), - ), - ), - ); - } - return yield* launchAttempt(); + // An attempt that loses a port bind before it is ready relaunches on fresh ports. + const supervise = nextAttempt.pipe( + Effect.flatMap(observeAttempt), + Effect.retry({ + times: nativeLaunchAttempts - 1, + while: (error) => + error._tag === "NativePortCollision" + ? Clock.currentTimeMillis.pipe(Effect.map((now) => now < deadline)) + : Effect.succeed(false), + }), + Effect.catchTag("NativePortCollision", ({ failure }) => settleFailure(failure)), + ); + + return { + health: supervise, + probe: Ref.get(live).pipe( + Effect.flatMap((attempt) => readyCondition(attempt, probeTimeout)), + ), + exit: Deferred.await(settled), + stop: Ref.get(live).pipe( + Effect.flatMap((attempt) => attempt.native.kill), + Effect.mapError((cause) => serviceError("stop", cause)), + ), + remove: Ref.set(endpoints, new Map()), + } satisfies RuntimeSession; } if (deps.container === undefined) return yield* serviceError("launch", "Container runtime unavailable"); @@ -850,12 +855,19 @@ export const makeProcessRecipe = yield* publishLogs(launched, logs, context.scope); const runtime = runtimeFromContainer(launched); const ready = selected.get("http"); + const noReadinessEndpoint = Effect.fail( + serviceError("health", "Recipe has no HTTP readiness endpoint"), + ); return { ...runtime, health: ready === undefined - ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) + ? noReadinessEndpoint : readiness(deps.client, ready, spec.healthPath), + probe: + ready === undefined + ? noReadinessEndpoint + : readiness(deps.client, ready, spec.healthPath, probeTimeout), remove: runtime.remove.pipe(Effect.tap(() => Ref.set(endpoints, new Map()))), } satisfies RuntimeSession; }); diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 44e2a39e0d..ab3e24dcd2 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -638,27 +638,20 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("destroys legacy host data without a storage marker", () => + it.live("refuses to start unmarked data and removes it through the helper on destroy", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const helperImage = yield* postgresImage("17"); - const root = yield* fs.makeTempDirectoryScoped({ prefix: "docker-storage-legacy-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "docker-storage-unmarked-" }); const storageRoot = path.join(root, "state", "stack", "data"); const cacheRoot = path.join(root, "cache"); - const instanceRoot = path.join(storageRoot, "legacy"); + const instanceRoot = path.join(storageRoot, "unmarked"); const dataRoot = path.join(instanceRoot, "data"); - yield* fs.makeDirectory(dataRoot, { recursive: true }); - yield* fs.writeFileString(path.join(dataRoot, "PG_VERSION"), "17\n"); - yield* fs.writeFileString(path.join(dataRoot, "fixture"), "legacy"); - yield* fs.writeFileString( - path.join(instanceRoot, ".supabase-database-ready.json"), - '{"version":"17","runtime":"docker","profile":"supabase"}', - ); - const container = yield* makeContainerRuntime({ engine: "docker", root }); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* fs.makeDirectory(path.join(dataRoot, "base"), { recursive: true }); + yield* fs.writeFileString(path.join(dataRoot, "base", "fixture"), "unmarked"); yield* docker([ "run", "--rm", @@ -667,12 +660,14 @@ describe("Docker database storage", { timeout: 120_000 }, () => { helperImage, "/bin/sh", "-c", - "chown -R 100:101 /instance/data; chmod 700 /instance/data", + "chown -R 100:101 /instance/data; chmod -R 700 /instance/data", ]); + const container = yield* makeContainerRuntime({ engine: "docker", root }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const storage = yield* makeDockerDatabaseStorage({ runtime: "docker", - stackId: `storage-legacy-${yield* crypto.randomUUIDv4}`, - instanceId: "legacy", + stackId: `storage-unmarked-${yield* crypto.randomUUIDv4}`, + instanceId: "unmarked", instanceRoot, root: storageRoot, cacheRoot, @@ -682,15 +677,52 @@ describe("Docker database storage", { timeout: 120_000 }, () => { container, spawner, }); - expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-storage.json"))).toBe( - false, - ); + const markerPath = path.join(instanceRoot, ".supabase-database-storage.json"); + + const failure = yield* storage.prepare("17").pipe(Effect.flip); + expect(failure.message).toContain("without a storage marker"); + expect(yield* fs.exists(markerPath)).toBe(false); + yield* storage.destroyData("17"); expect(yield* fs.exists(dataRoot)).toBe(false); - expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-ready.json"))).toBe( + expect(yield* fs.exists(markerPath)).toBe(false); + yield* fs.remove(cacheRoot, { recursive: true, force: true }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("refuses to adopt unmarked Podman data at startup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "podman-storage-unmarked-" }); + const storageRoot = path.join(root, "state", "stack", "data"); + const instanceRoot = path.join(storageRoot, "unmarked"); + const dataRoot = path.join(instanceRoot, "data"); + yield* fs.makeDirectory(dataRoot, { recursive: true }); + yield* fs.writeFileString(path.join(dataRoot, "PG_VERSION"), "17\n"); + const storage = yield* makeDockerDatabaseStorage({ + runtime: "podman", + stackId: "storage-podman-unmarked", + instanceId: "unmarked", + instanceRoot, + root: storageRoot, + cacheRoot: path.join(root, "cache"), + fs, + path, + crypto, + container: yield* makeContainerRuntime({ engine: "podman", root }), + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + }); + + const failure = yield* storage.prepare("17").pipe(Effect.flip); + expect(failure.message).toContain("without a storage marker"); + expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-storage.json"))).toBe( false, ); - yield* fs.remove(cacheRoot, { recursive: true, force: true }); + expect(yield* fs.readFileString(path.join(dataRoot, "PG_VERSION"))).toBe("17\n"); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -926,7 +958,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("adopts root-owned host data through helper operations", () => + it.live("handles root-owned host data through helper operations", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -941,6 +973,19 @@ describe("Docker database storage", { timeout: 120_000 }, () => { const dataRoot = path.join(instanceRoot, "data"); yield* fs.makeDirectory(dataRoot, { recursive: true }); yield* fs.makeDirectory(cacheRoot, { recursive: true }); + // A storage marker always precedes data on disk; write it directly here to set up + // a host-backed, initialized instance without going through that normal sequence. + const initialMarker = yield* Schema.encodeEffect(Schema.fromJsonString(Marker))({ + backend: "host", + namespace: "instance-storage-host-test-adopted", + cacheNamespace: `cache-${"0".repeat(32)}`, + initialized: true, + }); + yield* fs.writeFileString( + path.join(instanceRoot, ".supabase-database-storage.json"), + initialMarker, + { mode: 0o600 }, + ); yield* fs.writeFileString(path.join(dataRoot, "PG_VERSION"), "17\n"); yield* fs.writeFileString(path.join(dataRoot, "fixture"), "adopted"); yield* fs.writeFileString( diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 116eb851bb..51b81510e6 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -109,14 +109,21 @@ const errorFor = (operation: string, cause: unknown) => const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; +// The helper image is built externally and must ship busybox and a reflink-capable `cp`; +// preflighting fails fast naming the missing binary instead of a confusing error deep in +// the lock or copy step. const withSnapshotLock = (root: string, command: string): string => - `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(root)}; exec 9>${shellQuote(`${root}/.lock`)}; attempt=0; until lock_error=$(/usr/bin/busybox flock -n 9 2>&1); do if [ -n "$lock_error" ]; then echo "$lock_error" >&2; exit 1; fi; if [ "$attempt" -ge 120 ]; then echo 'Timed out waiting for database snapshot lock' >&2; exit 1; fi; attempt=$((attempt + 1)); /usr/bin/busybox sleep 1; done; /usr/bin/sh -eu -c ${shellQuote(command)}`; + `set -eu; for helper_binary in /usr/bin/busybox /usr/local/bin/cp; do command -v "$helper_binary" >/dev/null 2>&1 || { echo "Database snapshot helper image is missing required binary: $helper_binary" >&2; exit 97; }; done; /usr/bin/busybox mkdir -p ${shellQuote(root)}; exec 9>${shellQuote(`${root}/.lock`)}; attempt=0; until lock_error=$(/usr/bin/busybox flock -n 9 2>&1); do if [ -n "$lock_error" ]; then echo "$lock_error" >&2; exit 1; fi; if [ "$attempt" -ge 120 ]; then echo 'Timed out waiting for database snapshot lock' >&2; exit 1; fi; attempt=$((attempt + 1)); /usr/bin/busybox sleep 1; done; /usr/bin/sh -eu -c ${shellQuote(command)}`; const parseMajor = (version: string): number | undefined => { const major = Number(version.trim().split(".")[0]); return Number.isInteger(major) ? major : undefined; }; +/** Derives the shared Docker volume name from a state-root/daemon identity digest. */ +export const volumeNameFor = (stateDigest: string): string => + `supabase-db-${stateDigest.slice(0, 32)}`; + /** Owns the placement and lifecycle of one database's Docker data and snapshot namespaces. */ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make")( (options: { @@ -164,6 +171,26 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") forceLive: boolean, ) => Effect.Effect = () => Effect.die("Docker daemon identity was resolved before the engine command existed"); + const hostData = options.path.join(options.instanceRoot, "data"); + const hasUnmarkedData = Effect.gen(function* () { + if ( + !(yield* options.fs + .exists(hostData) + .pipe(Effect.mapError((cause) => errorFor("data", cause)))) + ) + return false; + return yield* options.fs.readDirectory(hostData).pipe( + Effect.map((entries) => entries.length > 0), + // An inaccessible directory cannot be proven empty; treat it as non-empty. + Effect.orElseSucceed(() => true), + ); + }); + // This package always writes the storage marker before populating data, so non-empty + // unmarked data indicates a corrupted state rather than legacy data. + const rejectUnmarkedData = Effect.gen(function* () { + if (yield* hasUnmarkedData) + return yield* errorFor("data", "Database data exists without a storage marker"); + }); const selectedCache = yield* Ref.make(undefined); const selectionLock = yield* Semaphore.make(1); const selected = selectionLock.withPermit( @@ -204,20 +231,12 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") } return marker; } - const hostData = options.path.join(options.instanceRoot, "data"); - const initialized = - (yield* options.fs - .exists(hostData) - .pipe(Effect.mapError((cause) => errorFor("data", cause)))) && - (yield* options.fs.readDirectory(hostData).pipe( - Effect.map((entries) => entries.length > 0), - Effect.orElseSucceed(() => true), - )); + yield* rejectUnmarkedData; const value: Marker = { backend: "host", namespace: dataNamespace, cacheNamespace, - initialized, + initialized: false, }; const encoded = yield* encodeMarker(value); yield* options.fs @@ -289,31 +308,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") .pipe(Effect.mapError((cause) => errorFor("marker", cause))); return updated; } - const hostData = options.path.join(options.instanceRoot, "data"); - if ( - yield* options.fs - .exists(hostData) - .pipe(Effect.mapError((cause) => errorFor("data", cause))) - ) { - const entries = yield* options.fs.readDirectory(hostData).pipe( - // A legacy data directory may be owned by PostgreSQL's container UID. - // Let the root helper validate and adopt it instead of treating EACCES as empty. - Effect.orElseSucceed(() => ["inaccessible-data"]), - ); - if (entries.length > 0) { - const value: Marker = { - backend: "host", - namespace: dataNamespace, - cacheNamespace: `cache-${resolved.cacheDigest.slice(0, 32)}`, - initialized: true, - }; - const encoded = yield* encodeMarker(value); - yield* options.fs - .writeFileString(markerPath, encoded, { mode: 0o600 }) - .pipe(Effect.mapError((cause) => errorFor("marker", cause))); - return value; - } - } + yield* rejectUnmarkedData; // Docker 26 introduced volume-subpath. Older engines retain the host-backed path. const backend = resolved.clientMajor >= 26 && resolved.serverMajor >= 26 ? "docker" : "host"; @@ -456,7 +451,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.map(hash(`${canonicalStateRoot}\0${identity.daemonId}`), (stateDigest) => ({ ...identity, stateDigest, - volume: `supabase-db-${stateDigest.slice(0, 32)}`, + volume: volumeNameFor(stateDigest), cacheDigest, observed, volumeConfirmed, @@ -496,7 +491,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") .realPath(stateRoot) .pipe(Effect.mapError((cause) => errorFor("identity", cause))); const stateDigest = yield* hash(`${canonicalStateRoot}\0${resolved.daemonId}`); - const expectedVolume = `supabase-db-${stateDigest.slice(0, 32)}`; + const expectedVolume = volumeNameFor(stateDigest); if (marker.volume !== expectedVolume) return yield* errorFor( "destroy", @@ -778,23 +773,33 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }); const getMarkerForRemoval = Effect.gen(function* () { const existing = yield* getMarkerIfPresent; - if (Option.isSome(existing)) { - if (options.runtime === "docker") { - yield* selected; - return Option.some(yield* getMarker); - } - return existing; + if (Option.isSome(existing) && options.runtime === "docker") { + yield* selected; + return Option.some(yield* getMarker); } - const data = options.path.join(options.instanceRoot, "data"); - if (!(yield* options.fs.exists(data))) return Option.none(); - const nonEmpty = yield* options.fs.readDirectory(data).pipe( - Effect.map((entries) => entries.length > 0), - Effect.orElseSucceed(() => true), - ); - if (!nonEmpty) return Option.none(); - yield* selected; - return Option.some(yield* getMarker); + return existing; }); + const readyMarkerPath = options.path.join( + options.instanceRoot, + ".supabase-database-ready.json", + ); + // The host owns this file; a helper's bind-mount view can still list it after the host + // removes it, which makes the helper's unlink fail. + const removeReadyMarker = options.fs + .remove(readyMarkerPath, { force: true }) + .pipe(Effect.mapError((cause) => errorFor("reset", cause))); + /** Unmarked data cannot start, so removal is its only in-product recovery. */ + const removeUnmarkedData = (version: string) => + Effect.gen(function* () { + if (!(yield* hasUnmarkedData)) return; + yield* runHelper( + "set -eu; rm -rf /instance/data /instance/.supabase-restore-*", + [{ source: options.instanceRoot, target: "/instance", readOnly: false }], + version, + true, + ); + yield* removeReadyMarker; + }); const writeMarker = (marker: Marker) => encodeMarker(marker).pipe( Effect.flatMap((encoded) => @@ -915,7 +920,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const removeData = Effect.fn("DockerDatabaseStorage.removeData")((version: string) => Effect.gen(function* () { const markerOption = yield* getMarkerForRemoval; - if (Option.isNone(markerOption)) return; + if (Option.isNone(markerOption)) return yield* removeUnmarkedData(version); const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( @@ -932,27 +937,26 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") true, ); } - yield* options.fs - .remove(options.path.join(options.instanceRoot, ".supabase-database-ready.json"), { - force: true, - }) - .pipe(Effect.mapError((cause) => errorFor("reset", cause))); + yield* removeReadyMarker; yield* writeMarker({ ...marker, initialized: false }); }).pipe(Effect.mapError((cause) => errorFor("reset", cause))), ); const destroyData = Effect.fn("DockerDatabaseStorage.destroyData")((version: string) => Effect.gen(function* () { - const present = yield* getMarkerIfPresent; - const markerOption = Option.isSome(present) ? present : yield* getMarkerForRemoval; - if (Option.isNone(markerOption)) return; + const markerOption = yield* getMarkerIfPresent; + if (Option.isNone(markerOption)) { + yield* removeUnmarkedData(version); + return yield* removeHelper(); + } const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( - `set -eu; rm -rf /instance/data /instance/.supabase-restore-* /instance/.supabase-database-ready.json`, + `set -eu; rm -rf /instance/data /instance/.supabase-restore-*`, snapshotPaths(marker).mounts, version, true, ); + yield* removeReadyMarker; yield* removeHelper(); } else { yield* validateDockerMarkerIdentity(marker); diff --git a/packages/stack/tests/docker-fixture.ts b/packages/stack/tests/docker-fixture.ts index c315612a5d..f986eb7931 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -1,5 +1,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { Crypto, Effect, FileSystem, Path, Stream } from "effect"; +import { volumeNameFor } from "../src/storage/DockerDatabaseStorage.ts"; import { cleanupDockerRoot } from "./docker-cleanup.ts"; /** Runs a Docker CLI command and returns its combined output and exit code. */ @@ -48,7 +49,7 @@ export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), ), ); - const volume = `supabase-db-${stateDigest.slice(0, 32)}`; + const volume = volumeNameFor(stateDigest); yield* Effect.addFinalizer(() => Effect.gen(function* () { const inspected = yield* runDocker([ From d4826ec172c5d6ca778a4754568b16f756bd7171 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 11:57:12 +0000 Subject: [PATCH 11/71] refactor(stack): flatten the owner process layers (#6836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The owner process had accumulated parallel structures: - RPC handlers that only forwarded to `Owner`, with errors rewrapped through three types. - Seven maps keyed by instance id, and the composition held in two places. - Service-specific routes and credential rules inside `Owner.ts`. Definition changes ran on the caller's fiber. A client disconnecting mid-`createService` could leave an instance in `state.json` that the live owner didn't know about, or leak a network namespace so that the next destroy failed. ## Change - `Owner` builds the RPC handlers directly, with one error mapping. `OwnerError`, the mirrored interface and the pass-through handlers are gone. - One instance registry and one composition, both owned by the orchestrator. - Credential rules move to `host/Credentials` and shared routes to `host/Endpoints`. - Definition changes run in the owner scope: - A disconnected caller ends only its own wait. - Shutdown waits for changes in flight and rejects new ones. - Config-changing restarts go through the same gate. - A failed creation rolls back credentials it introduced. - One container sweep path, one snapshot/reset helper, and typed saved state with unique instance ids. - RPC error messages are always strings. - Unused orchestrator operations and RPCs are deleted. ## Stack Part 3 of 8 of the stack package simplification, based on #6835. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers ← this PR 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API --------- Co-authored-by: Claude Opus 5.5 --- .../db/reset/reset.integration.test.ts | 5 +- .../stack/prepare/prepare.errors.ts | 13 +- .../stack/status/status.handler.ts | 6 +- packages/stack/ARCHITECTURE.md | 14 +- .../stack/src/Network.integration.test.ts | 2 +- packages/stack/src/Network.ts | 28 +- .../src/Orchestrator.integration.test.ts | 20 +- packages/stack/src/Orchestrator.ts | 141 +- packages/stack/src/Owner.integration.test.ts | 374 ++-- packages/stack/src/Owner.ts | 1902 +++++------------ packages/stack/src/Rpc.ts | 83 +- packages/stack/src/Rpc.unit.test.ts | 46 + ....container-sweep-retry.integration.test.ts | 7 - .../stack/src/StackHost.integration.test.ts | 378 +++- packages/stack/src/StackHost.ts | 194 +- packages/stack/src/State.integration.test.ts | 53 +- packages/stack/src/State.ts | 18 +- .../Supabase.native.integration.test.ts | 204 +- packages/stack/src/composition/Supabase.ts | 38 +- packages/stack/src/effect.ts | 35 +- packages/stack/src/host/Credentials.ts | 203 ++ packages/stack/src/host/Endpoints.ts | 166 +- .../stack/src/internal/failure-message.ts | 13 + packages/stack/src/services/Catalog.ts | 2 - .../src/services/Rest.integration.test.ts | 2 +- .../stack/src/shutdown.integration.test.ts | 12 +- packages/stack/tests/owner-rpc.ts | 25 + packages/stack/tests/state-lock-fixture.ts | 2 +- 28 files changed, 1870 insertions(+), 2116 deletions(-) create mode 100644 packages/stack/src/Rpc.unit.test.ts create mode 100644 packages/stack/src/host/Credentials.ts create mode 100644 packages/stack/src/internal/failure-message.ts create mode 100644 packages/stack/tests/owner-rpc.ts diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 56feb36ca9..6158f75b0e 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -803,7 +803,10 @@ function mockResetStackApi(opts: { id: RESET_STACK_ID, identity: { projectRoot: opts.workdir, branchContext: "main", stackName: "default" }, runtime: "native" as const, - instances: members.map((member) => ({ id: member.id, creation: {} })), + instances: members.map((member) => ({ + id: member.id, + creation: { service: "mail" as const, config: {} }, + })), composition: { members: [], dependencies: [] }, ports: [], }, diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts index 7ceff0b000..59c97f1641 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -1,5 +1,5 @@ import { StackError } from "@supabase/stack/effect"; -import { Data } from "effect"; +import { Data, Schema } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, @@ -37,12 +37,11 @@ export const stackPrepareError = (error: unknown): StackCommandPrepareError => { ? error.message : String(error); return new StackCommandPrepareError({ - reason: - error instanceof StackError - ? error.operation === "prepareService" - ? "artifact" - : "unknown" - : "unknown", + reason: Schema.is(StackError)(error) + ? error.operation === "prepareService" + ? "artifact" + : "unknown" + : "unknown", message, cause: error, }); diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 9301667248..21ddf8f87e 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -96,16 +96,14 @@ const mapStackError = (error: StackError) => reason: error.operation === "open" || error.operation === "discover" || - error.operation === "definition" || - error.operation === "getComposition" + error.operation === "definition" ? "invalid-config" : "runtime", message: error.message, suggestion: error.operation === "open" || error.operation === "discover" || - error.operation === "definition" || - error.operation === "getComposition" + error.operation === "definition" ? "Inspect the saved stack state under $SUPABASE_HOME/stacks." : "Retry the command and use --debug if the stack remains unavailable.", cause: error, diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 0f6fc97f4f..30aad108ef 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -427,7 +427,17 @@ flowchart TB end ``` -Keep Effect RPC as the transport initially. Its handlers should mostly delegate to the same operations used by internal components. Composition startup calls the executors directly, not RPC back into its own host. Replacing RPC with handwritten messages would still require framing, validation, errors and stream transport; that replacement is not part of this simplification. +Keep Effect RPC as the transport initially. Composition startup calls the executors directly, not RPC back into its own host. Replacing RPC with handwritten messages would still require framing, validation, errors and stream transport; that replacement is not part of this simplification. + +| Module | Responsibility | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `StackHost` | Process lifetime: signals, control-port claim, startup container sweep, shutdown state machine, `/identity` and `/rpc`; serves the owner's handlers plus shutdown and command RPCs | +| `Owner` | Builds the instance and composition RPC handlers, maps domain failures to `StackError` once, persists definitions and adapts recipes, listeners and the Supabase composition | +| `Orchestrator` | The single in-memory registry of instance entries and the composition: admission, start plans, activity, idle sleep and exit watchers | +| `Network` | Public listeners, the shared API proxy and port claims | +| `host/*` | Service-specific endpoint routes, rendered connection values and stack credential rules | + +Definition changes (service creation and destruction, composition configuration and Supabase composition) run one at a time in the owner's scope, and a caller that disconnects stops waiting. Creation saves the instance before registering it and removes the saved instance again if registration fails. Until registration completes, a concurrent `get` or `list` can already return the new id, while `start` or `status` for it fails with an unknown-instance error. ### Proposed RPC surface @@ -600,7 +610,7 @@ The endpoint renderer produces host-facing or stack-runtime-facing connection va Mutable files live under the stack namespace; container resources carry equivalent identity labels. Each managed container is addressed by its unique preassigned launch name for its whole owned lifetime; renaming a managed container is outside the lifecycle contract. Shared immutable artifact caches and host-wide port coordination are justified exceptions. User-requested exports can live at their chosen destination. -The StackHost serializes updates to saved instance definitions, composition wiring and resource assignments. Lifecycle, health, active operations, runtime handles and errors remain in the live instance observation. There is no durable lifecycle/operation journal, projected capability state or duplicate stack lifecycle state. +The owner serializes updates to saved instance definitions, composition wiring and resource assignments. Lifecycle, health, active operations, runtime handles and errors remain in the live instance observation. There is no durable lifecycle/operation journal, projected capability state or duplicate stack lifecycle state. Persistence supports reopening normally stopped instances, not reconstructing interrupted execution after owner loss. Do not infer current runtime state from saved configuration. When the host is absent or unreachable, expose the saved definitions and ports separately from unavailable live observations. Container cleanup after owner loss uses live daemon labels, not persisted process state; service recovery, resource adoption, interrupted-operation replay and private-format migration machinery are outside scope. diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index a5351f9a16..6d07416b0e 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -27,7 +27,7 @@ const stack = (id: string, port: number | "auto") => ({ identity: { projectRoot: "/tmp", branchContext: "test", stackName: id }, runtime: "native" as const, instances: [], - composition: {}, + composition: { members: [], dependencies: [] }, ports: port === "auto" ? [] : [{ key: "api", host: "127.0.0.1", port }], }); diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index 8c1b1cb8f7..f47b9a6274 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -16,13 +16,13 @@ export interface NetworkEndpoint { readonly enabled: Effect.Effect; } -class NetworkError extends Data.TaggedError("NetworkError")<{ +export class NetworkError extends Data.TaggedError("NetworkError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; }> {} -export interface NetworkBinding { +interface NetworkBinding { readonly name: string; readonly protocol: "tcp" | "http"; readonly host: string; @@ -77,7 +77,6 @@ const makeNetwork = (options: { } | undefined >(undefined); - const namespaces = yield* Ref.make(new Map>()); const listenHost = options.runtime === "native" ? "127.0.0.1" : "0.0.0.0"; const hostAddress = "127.0.0.1"; @@ -95,12 +94,6 @@ const makeNetwork = (options: { readonly id: string; readonly endpoints: Readonly>; }) { - const names = Object.keys(endpoints); - const duplicate = yield* Ref.modify(namespaces, (current) => [ - current.has(id), - current.has(id) ? current : new Map(current).set(id, names), - ]); - if (duplicate) return yield* errorFor("register", `Duplicate instance ${id}`); const bound = yield* Ref.make>(new Map()); const scopes = yield* Ref.make>(new Map()); const closed = yield* Ref.make(false); @@ -249,18 +242,13 @@ const makeNetwork = (options: { "Stop the instance before releasing its endpoints", ); yield* Ref.set(closed, true); - for (const name of names) { + for (const name of Object.keys(endpoints)) { const endpoint = endpoints[name]; if (endpoint?.shared === undefined) yield* ports .release(options.stackId, `${id}:${name}`) .pipe(Effect.mapError((cause) => errorFor("release", cause))); } - yield* Ref.update(namespaces, (current) => { - const next = new Map(current); - next.delete(id); - return next; - }); }), ), ), @@ -295,13 +283,9 @@ const makeNetwork = (options: { const release = Effect.fn("Network.releaseNamespace")(() => gate.withPermits(1)( - Effect.gen(function* () { - if ((yield* Ref.get(namespaces)).size !== 0) - return yield* errorFor("release", "Destroy instances before releasing the namespace"); - yield* ports - .release(options.stackId, "api") - .pipe(Effect.mapError((cause) => errorFor("release", cause))); - }), + ports + .release(options.stackId, "api") + .pipe(Effect.mapError((cause) => errorFor("release", cause))), ), ); return { register, release: release() } satisfies Interface; diff --git a/packages/stack/src/Orchestrator.integration.test.ts b/packages/stack/src/Orchestrator.integration.test.ts index 93a2a075f1..72dddf8bde 100644 --- a/packages/stack/src/Orchestrator.integration.test.ts +++ b/packages/stack/src/Orchestrator.integration.test.ts @@ -1,28 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Fiber, - Layer, - Option, - Ref, - Schema, - Scope, - Stream, -} from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Schema, Scope, Stream } from "effect"; import * as TestClock from "effect/testing/TestClock"; import * as Orchestrator from "./Orchestrator.ts"; import type { RegisteredInstance } from "./Orchestrator.ts"; import { makeService, ServiceError } from "./Service.ts"; const failure = (message: string) => new ServiceError({ operation: "fixture", message }); -const makeTestOrchestrator = () => - Layer.build(Layer.fresh(Orchestrator.layer)).pipe( - Effect.map((context) => Context.get(context, Orchestrator.Service)), - ); +const makeTestOrchestrator = () => Orchestrator.make(); const makeInstance = ( orchestrator: Orchestrator.Interface, id: string, diff --git a/packages/stack/src/Orchestrator.ts b/packages/stack/src/Orchestrator.ts index 0f00f78e92..839d2d0af1 100644 --- a/packages/stack/src/Orchestrator.ts +++ b/packages/stack/src/Orchestrator.ts @@ -1,13 +1,11 @@ import { Cause, Clock, - Context, Data, Deferred, Effect, Exit, FiberMap, - Layer, Match, Ref, Schema, @@ -35,7 +33,6 @@ export type OrchestratorOperation = | "close" | "configure" | "register" - | "unregister" | "proxy"; export class OrchestratorError extends Data.TaggedError("OrchestratorError")<{ @@ -73,7 +70,6 @@ interface RegisteredCore { readonly ready: Effect.Effect; readonly stop: Effect.Effect; readonly destroy: Effect.Effect; - readonly arm: Effect.Effect; readonly armAt: ( revision: number, guard?: Effect.Effect, @@ -151,27 +147,25 @@ export const CompositionConfig = Schema.Struct({ ), }); -export interface Interface { +/** The lifecycle authority for one stack's registered instances and their composition. */ +export interface Interface { readonly admissionFor: ( id: string, ) => ( operation: ServiceAdmission, transition: Effect.Effect, ) => Effect.Effect; - readonly register: ( - instance: RegisteredInstance, - ) => Effect.Effect; - readonly unregister: (id: string) => Effect.Effect; - readonly get: ( - id: string, - ) => Effect.Effect; - readonly list: Effect.Effect< - ReadonlyArray, - OrchestratorError | LifecycleError - >; - readonly configure: ( - configuration: unknown, - ) => Effect.Effect; + readonly register: (entry: Entry) => Effect.Effect; + readonly get: (id: string) => Effect.Effect; + readonly composition: Effect.Effect; + /** + * Validates and installs a composition; a failed `persist` keeps the previous one. + * `persist` runs under the graph gate, so callers take the cross-process state lock first. + */ + readonly configure: ( + configuration: CompositionConfig, + persist?: Effect.Effect, + ) => Effect.Effect; readonly start: (id: string) => Effect.Effect; readonly stop: (id: string) => Effect.Effect; readonly restart: ( @@ -199,10 +193,6 @@ export interface Interface { ) => Effect.Effect; } -export class Service extends Context.Service()( - "@supabase/stack/Orchestrator", -) {} - interface ActivityState { readonly active: number; readonly lastActivity: number; @@ -246,11 +236,14 @@ const topo = ( return result; }; -const makeOrchestrator = Effect.gen(function* () { +/** Builds an orchestrator whose watchers and idle timers live in the current scope. */ +export const make = Effect.fn("Orchestrator.make")(function* < + Entry extends RegisteredInstance, +>(): Effect.fn.Return, never, Scope.Scope> { const owner = yield* Scope.Scope; const graphGate = yield* Semaphore.make(1); const idleTimers = yield* FiberMap.make(); - const registry = yield* Ref.make>(new Map()); + const registry = yield* Ref.make>(new Map()); const composition = yield* Ref.make({ members: [], dependencies: [] }); const activity = yield* Ref.make>(new Map()); @@ -264,7 +257,7 @@ const makeOrchestrator = Effect.gen(function* () { const node = Effect.fn("Orchestrator.node")(function* ( id: string, - ): Effect.fn.Return { + ): Effect.fn.Return { const nodes = yield* Ref.get(registry); const value = nodes.get(id); if (value === undefined) return yield* graphError("register", `Unknown instance ${id}`); @@ -544,14 +537,11 @@ const makeOrchestrator = Effect.gen(function* () { Effect.gen(function* () { const values = yield* Ref.get(activity); const current = values.get(id); - if ( + return !( current === undefined || current.active > 0 || now - current.lastActivity < timeout - ) - return false; - yield* Ref.set(activity, new Map(values).set(id, { ...current })); - return true; + ); }), ); if (state) { @@ -736,6 +726,9 @@ const makeOrchestrator = Effect.gen(function* () { const next = new Map(values); next.delete(id); yield* Ref.set(registry, next); + const activities = new Map(yield* Ref.get(activity)); + activities.delete(id); + yield* Ref.set(activity, activities); const configured = yield* Ref.get(composition); yield* Ref.set(composition, { members: configured.members.filter((member) => member.id !== id), @@ -778,7 +771,7 @@ const makeOrchestrator = Effect.gen(function* () { }); }); - const orchestrator: Interface = { + const orchestrator: Interface = { admissionFor, register: Effect.fn("Orchestrator.register")((instance) => withGraph( @@ -832,66 +825,34 @@ const makeOrchestrator = Effect.gen(function* () { }), ), ), - unregister: Effect.fn("Orchestrator.unregister")((id) => + get: Effect.fn("Orchestrator.get")((id) => node(id)), + composition: Ref.get(composition), + configure: (configuration: CompositionConfig, persist?: Effect.Effect) => withGraph( Effect.gen(function* () { - const instance = yield* node(id); - const observation = yield* instance.core.get; - if (observation.registered || observation.lifecycle !== "stopped") - return yield* graphError("unregister", `Instance ${id} must be destroyed first`); - const structure = yield* configurationGraph(yield* Ref.get(composition)); - if ((structure.dependents.get(id) ?? []).some((dependentId) => dependentId !== id)) - return yield* graphError( - "unregister", - `Instance ${id} is still referenced by a dependent`, - ); - const values = yield* Ref.get(registry); - const next = new Map(values); - next.delete(id); - yield* Ref.set(registry, next); - const configured = yield* Ref.get(composition); - yield* Ref.set(composition, { - members: configured.members.filter((member) => member.id !== id), - dependencies: configured.dependencies.filter( - (dependency) => dependency.from !== id && dependency.to !== id, - ), - }); + const previous = yield* Ref.get(composition); + yield* configurationGraph(configuration); + const affected = new Set([ + ...previous.members.map((member) => member.id), + ...configuration.members.map((member) => member.id), + ]); + for (const dependency of [...previous.dependencies, ...configuration.dependencies]) { + affected.add(dependency.from); + affected.add(dependency.to); + } + for (const affectedId of affected) { + const instance = yield* node(affectedId); + const observation = yield* instance.core.get; + if (observation.lifecycle !== "stopped" || observation.wakeEnabled) + return yield* graphError( + "configure", + `Instance ${affectedId} must be stopped and wake-disabled`, + ); + } + if (persist !== undefined) yield* persist; + yield* Ref.set(composition, configuration); }), - ), - ), - get: Effect.fn("Orchestrator.get")((id) => node(id)), - list: Ref.get(registry).pipe(Effect.map((values) => [...values.values()])), - configure: Effect.fn("Orchestrator.configure")((configuration) => - Schema.decodeUnknownEffect(CompositionConfig)(configuration).pipe( - Effect.mapError((cause) => graphError("configure", "Invalid composition", cause)), - Effect.flatMap((decoded) => - withGraph( - Effect.gen(function* () { - const previous = yield* Ref.get(composition); - yield* configurationGraph(decoded); - const affected = new Set([ - ...previous.members.map((member) => member.id), - ...decoded.members.map((member) => member.id), - ]); - for (const dependency of [...previous.dependencies, ...decoded.dependencies]) { - affected.add(dependency.from); - affected.add(dependency.to); - } - for (const affectedId of affected) { - const instance = yield* node(affectedId); - const observation = yield* instance.core.get; - if (observation.lifecycle !== "stopped" || observation.wakeEnabled) - return yield* graphError( - "configure", - `Instance ${affectedId} must be stopped and wake-disabled`, - ); - } - yield* Ref.set(composition, decoded); - }), - ), - ), - ), - ), + ).pipe(Effect.withSpan("Orchestrator.configure")), start: Effect.fn("Orchestrator.start")((id) => Effect.gen(function* () { const plan = yield* snapshotPlan(id); @@ -975,5 +936,3 @@ const makeOrchestrator = Effect.gen(function* () { }; return orchestrator; }); - -export const layer = Layer.effect(Service, makeOrchestrator.pipe(Effect.map(Service.of))); diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 2bacd218a9..22316cb45c 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -1,13 +1,17 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { PgClient } from "@effect/sql-pg"; import { expect, it } from "@effect/vitest"; -import { Context, Effect, FileSystem, Layer, Path, Redacted, Ref } from "effect"; +import { Context, Effect, FileSystem, Layer, Redacted, Ref, Schema } from "effect"; import { HttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; +import { RpcTest } from "effect/unstable/rpc"; import * as Owner from "./Owner.ts"; +import { OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; import { DEFAULT_LOCAL_JWT_SECRET } from "./Defaults.ts"; +import { ServiceCreation } from "./services/Catalog.ts"; +import { ownerFor } from "../tests/owner-rpc.ts"; const stateFor = (root: string) => Effect.gen(function* () { @@ -15,21 +19,6 @@ const stateFor = (root: string) => return Context.get(context, State.Service); }); -const ownerFor = (options: { - readonly saved: SavedStack; - readonly state: State.Interface; - readonly root: string; - readonly cacheRoot: string; -}) => { - const { state, ...layerOptions } = options; - return Effect.gen(function* () { - const context = yield* Layer.build( - Owner.layer(layerOptions).pipe(Layer.provide(Layer.succeed(State.Service, state))), - ); - return Context.get(context, Owner.Service); - }); -}; - const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const initial = (id: string): SavedStack => ({ @@ -79,7 +68,7 @@ it.effect("credential lookup leaves a fresh stack untouched for custom database expect((yield* state.read(stack.id))?.credentials).toBeUndefined(); const jwtSecret = "custom-owner-credentials-jwt-secret-long-enough"; - yield* owner.services.create({ + yield* owner.rpc.createService({ service: "database", config: { version: "17", @@ -164,7 +153,8 @@ it.live("forwards and rotates saved identity across composed services in one own }); const customJwtSecret = "custom-owner-rotation-jwt-secret-long-enough"; const services = servicesFor(customJwtSecret); - const first = yield* owner.composition.supabase(services, { + const first = yield* owner.rpc.supabaseComposition({ + services: services, identity: identity("one", "[]"), }); const ids = first.map(({ id }) => id); @@ -213,7 +203,7 @@ it.live("forwards and rotates saved identity across composed services in one own }); expect(firstAuth.config.gotrueJwtKeys).toBe(firstCredentials.gotrueJwtKeys); - const standaloneRest = yield* owner.services.create({ + const standaloneRest = yield* owner.rpc.createService({ service: "rest", config: { databaseUrl: "postgresql://placeholder" }, endpoints: {}, @@ -222,7 +212,7 @@ it.live("forwards and rotates saved identity across composed services in one own jwtSecret: customJwtSecret, jwks: firstCredentials.jwks, }); - const standaloneAuth = yield* owner.services.create({ + const standaloneAuth = yield* owner.rpc.createService({ service: "auth", config: { databaseUrl: "postgresql://placeholder" }, endpoints: {}, @@ -231,7 +221,7 @@ it.live("forwards and rotates saved identity across composed services in one own jwtSecret: customJwtSecret, gotrueJwtKeys: firstCredentials.gotrueJwtKeys, }); - const standaloneStorage = yield* owner.services.create({ + const standaloneStorage = yield* owner.rpc.createService({ service: "storage", config: { databaseUrl: "postgresql://placeholder", filePath: `${root}/standalone-uploads` }, endpoints: {}, @@ -242,7 +232,7 @@ it.live("forwards and rotates saved identity across composed services in one own anonKey: firstCredentials.anonKey, serviceRoleKey: firstCredentials.serviceRoleKey, }); - const standaloneFunctions = yield* owner.services.create({ + const standaloneFunctions = yield* owner.rpc.createService({ service: "functions", config: { functionsRoot: `${root}/standalone-functions`, @@ -258,12 +248,13 @@ it.live("forwards and rotates saved identity across composed services in one own serviceRoleKey: firstCredentials.serviceRoleKey, }); - yield* owner.composition.stop; + yield* owner.rpc.stopComposition(); const secondServices = servicesFor().filter((creation) => creation.service !== "studio"); const studioId = first.find(({ creation }) => creation.service === "studio")?.id; if (studioId === undefined) return yield* Effect.die("Studio ID is missing"); const retainedIds = ids.filter((id) => id !== studioId); - const second = yield* owner.composition.supabase(secondServices, { + const second = yield* owner.rpc.supabaseComposition({ + services: secondServices, identity: identity("two", "[{}]"), reuseIds: retainedIds, }); @@ -295,15 +286,17 @@ it.live("forwards and rotates saved identity across composed services in one own gotrueJwtKeys: secondCredentials.gotrueJwtKeys, }); expect(second.some(({ id }) => id === studioId)).toBe(false); - expect((yield* owner.core.get(studioId)).lifecycle).toBe("stopped"); - const excludedStudio = yield* owner.services.get(studioId); - expect(excludedStudio.creation.service).toBe("studio"); - if (excludedStudio.creation.service === "studio") - expect(excludedStudio.creation.config.anonKey).toBe("anon-one"); - for (const id of retainedIds) expect((yield* owner.core.get(id)).lifecycle).toBe("stopped"); - - yield* owner.composition.stop; - const third = yield* owner.composition.supabase(servicesFor(customJwtSecret), { + expect((yield* owner.rpc.status({ id: studioId })).lifecycle).toBe("stopped"); + const excludedStudio = (yield* owner.rpc.status({ id: studioId })).config; + expect(excludedStudio.service).toBe("studio"); + if (excludedStudio.service === "studio") + expect(excludedStudio.config.anonKey).toBe("anon-one"); + for (const id of retainedIds) + expect((yield* owner.rpc.status({ id: id })).lifecycle).toBe("stopped"); + + yield* owner.rpc.stopComposition(); + const third = yield* owner.rpc.supabaseComposition({ + services: servicesFor(customJwtSecret), identity: identity("three", "[]"), reuseIds: ids, }); @@ -316,8 +309,11 @@ it.live("forwards and rotates saved identity across composed services in one own publishableKey: "publishable-three", }); - const credentialConflict = yield* owner.composition - .supabase(servicesFor("different-owner-jwt-secret-long-enough"), { reuseIds: ids }) + const credentialConflict = yield* owner.rpc + .supabaseComposition({ + services: servicesFor("different-owner-jwt-secret-long-enough"), + reuseIds: ids, + }) .pipe(Effect.flip); expect(credentialConflict.message).toContain("Credential override jwtSecret conflicts"); expect( @@ -327,32 +323,6 @@ it.live("forwards and rotates saved identity across composed services in one own ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); -it.effect("rejects a malformed persisted composition", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-malformed-" }); - const state = yield* stateFor(`${root}/state`); - const saved = initial("owner-malformed"); - yield* state.save(saved); - const malformed = - '{"id":"owner-malformed","identity":{"projectRoot":"/tmp/project","branchContext":"owner-test","stackName":"owner-malformed"},"runtime":"native","instances":[],"composition":{"members":"invalid","dependencies":[]},"ports":[]}'; - yield* fs.writeFileString(path.join(root, "state", saved.id, "state.json"), malformed); - const reopened = yield* state.read(saved.id); - if (reopened === undefined) return yield* Effect.die("saved state disappeared"); - const failure = yield* ownerFor({ - saved: reopened, - state, - root: `${root}/state/${saved.id}/data`, - cacheRoot, - }).pipe(Effect.flip); - expect(failure).toBeInstanceOf(Owner.OwnerError); - expect(failure.operation).toBe("configure"); - }), - ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), -); - it.effect("publishes service removal and composition pruning together", () => Effect.scoped( Effect.gen(function* () { @@ -384,17 +354,17 @@ it.effect("publishes service removal and composition pruning together", () => cacheRoot, }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const service = yield* owner.services.create({ + const service = yield* owner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" }, smtp: { port: "auto" }, pop3: { port: "auto" } }, }); - yield* owner.composition.configure({ + yield* owner.rpc.configureComposition({ members: [{ id: service.id, activation: "eager" }], dependencies: [], }); - yield* owner.core.destroy(service.id); + yield* owner.rpc.destroyService({ id: service.id }); const saved = yield* state.read(stack.id); if (saved === undefined) return yield* Effect.die("saved state disappeared"); @@ -422,7 +392,7 @@ it.live( cacheRoot, }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const database = yield* owner.services.create({ + const database = yield* owner.rpc.createService({ service: "database", config: { version: "17", @@ -432,12 +402,12 @@ it.live( }, endpoints: { sql: { port: "auto" } }, }); - const rest = yield* owner.services.create({ + const rest = yield* owner.rpc.createService({ service: "rest", config: { databaseUrl: "postgresql://placeholder" }, endpoints: { http: { port: "auto" } }, }); - const shadow = yield* owner.services.create({ + const shadow = yield* owner.rpc.createService({ service: "database", config: { version: "17", @@ -447,7 +417,7 @@ it.live( }, endpoints: { sql: { port: "auto" } }, }); - yield* owner.composition.configure({ + yield* owner.rpc.configureComposition({ members: [ { id: database.id, activation: "eager" }, { id: rest.id, activation: "lazy", idleMillis: 30_000 }, @@ -460,18 +430,18 @@ it.live( }, ], }); - yield* owner.core.start(shadow.id); - yield* owner.core.ready(shadow.id); - yield* owner.composition.start; - const dbCredentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startService({ id: shadow.id }); + yield* owner.rpc.readyService({ id: shadow.id }); + yield* owner.rpc.startComposition(); + const dbCredentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); const databaseUrl = dbCredentials.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("database credentials missing"); yield* query(databaseUrl, "CREATE TABLE owner_rows(value text NOT NULL)"); yield* query(databaseUrl, "INSERT INTO owner_rows(value) VALUES ('wake')"); - const restCredentials = yield* owner.credentials(rest.id, "host"); + const restCredentials = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); const restUrl = restCredentials.url; if (restUrl === undefined) return yield* Effect.die("REST credentials missing"); - const restObservation = yield* owner.core.get(rest.id); + const restObservation = yield* owner.rpc.status({ id: rest.id }); const restEndpoint = restObservation.endpoints.find((endpoint) => endpoint.name === "http"); expect(restEndpoint?.host).toBe("127.0.0.1"); expect(restEndpoint?.port).toBe(Number(new URL(restUrl).port)); @@ -480,14 +450,14 @@ it.live( expect(response.status).toBe(200); const body = yield* response.json; expect(body).toEqual([{ value: "wake" }]); - expect((yield* owner.core.get(shadow.id)).lifecycle).toBe("running"); - yield* owner.composition.stop; - expect((yield* owner.core.get(shadow.id)).lifecycle).toBe("running"); + expect((yield* owner.rpc.status({ id: shadow.id })).lifecycle).toBe("running"); + yield* owner.rpc.stopComposition(); + expect((yield* owner.rpc.status({ id: shadow.id })).lifecycle).toBe("running"); const firstPort = new URL(databaseUrl).port; - yield* owner.core.stop(database.id); - yield* owner.core.start(database.id); - yield* owner.core.ready(database.id); - const reopened = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.stopService({ id: database.id }); + yield* owner.rpc.startService({ id: database.id }); + yield* owner.rpc.readyService({ id: database.id }); + const reopened = yield* owner.rpc.credentials({ id: database.id, from: "host" }); if (reopened.databaseUrl === undefined) return yield* Effect.die("reopened credentials missing"); expect(new URL(reopened.databaseUrl).port).toBe(firstPort); @@ -499,8 +469,8 @@ it.live( cacheRoot, }); yield* Effect.addFinalizer(() => reopenedOwner.namespace.destroy.pipe(Effect.ignore)); - expect((yield* reopenedOwner.services.list).length).toBe(3); - expect((yield* reopenedOwner.core.get(database.id)).lifecycle).toBe("stopped"); + expect((yield* state.read(stack.id))?.instances).toHaveLength(3); + expect((yield* reopenedOwner.rpc.status({ id: database.id })).lifecycle).toBe("stopped"); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 180_000 }, @@ -527,23 +497,31 @@ it.effect("isolates owner graphs built in one scope", () => }).pipe(Layer.provide(Layer.succeed(State.Service, state))), memoMap, scope, - ).pipe(Effect.map((context) => Context.get(context, Owner.Service))); + ).pipe( + Effect.map((context) => Context.get(context, Owner.Service)), + Effect.flatMap((owner) => + RpcTest.makeClient(OwnerRpc).pipe( + Effect.provide(OwnerRpc.toLayer(owner.handlers)), + Effect.map((rpc) => ({ rpc, namespace: owner.namespace })), + ), + ), + ); const firstOwner = yield* buildOwner(first); const secondOwner = yield* buildOwner(second); yield* Effect.addFinalizer(() => firstOwner.namespace.destroy.pipe(Effect.ignore)); yield* Effect.addFinalizer(() => secondOwner.namespace.destroy.pipe(Effect.ignore)); - const created = yield* firstOwner.services.create({ + const created = yield* firstOwner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" }, smtp: { port: "auto" }, pop3: { port: "auto" } }, }); - const isolated = yield* secondOwner.composition - .configure({ + const isolated = yield* secondOwner.rpc + .configureComposition({ members: [{ id: created.id, activation: "eager" }], dependencies: [], }) .pipe(Effect.flip); - expect(isolated.operation).toBe("configure"); + expect(isolated.operation).toBe("configureComposition"); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -565,28 +543,30 @@ it.live( cacheRoot, }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const created = yield* owner.composition.supabase([ - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("owner-factory-password"), - jwtSecret: Redacted.make("owner-factory-jwt-secret-long-enough"), - jwtExpiry: 3600, + const created = yield* owner.rpc.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("owner-factory-password"), + jwtSecret: Redacted.make("owner-factory-jwt-secret-long-enough"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, }, - endpoints: { sql: { port: "auto" } }, - }, - { - service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "mail", - config: {}, - endpoints: { http: { port: "auto" }, smtp: { port: "auto" } }, - }, - ]); + { + service: "rest", + config: { databaseUrl: "postgresql://placeholder" }, + endpoints: { http: { port: "auto" } }, + }, + { + service: "mail", + config: {}, + endpoints: { http: { port: "auto" }, smtp: { port: "auto" } }, + }, + ], + }); const database = created.find((entry) => entry.creation.service === "database"); const rest = created.find((entry) => entry.creation.service === "rest"); const mail = created.find((entry) => entry.creation.service === "mail"); @@ -594,16 +574,16 @@ it.live( return yield* Effect.die("factory did not create all requested services"); if (rest.creation.service !== "rest") return yield* Effect.die("REST service missing"); expect(rest.creation.config.databaseUrl).toContain("authenticator:"); - yield* owner.composition.start; - const databaseCredentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const databaseCredentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); const databaseUrl = databaseCredentials.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("database URL missing"); yield* query(databaseUrl, "CREATE TABLE factory_rows(value text NOT NULL)"); yield* query(databaseUrl, "INSERT INTO factory_rows VALUES ('factory-row')"); - const restCredentials = yield* owner.credentials(rest.id, "host"); + const restCredentials = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); const restUrl = restCredentials.url; if (restUrl === undefined) return yield* Effect.die("REST URL missing"); - const mailCredentials = yield* owner.credentials(mail.id, "host"); + const mailCredentials = yield* owner.rpc.credentials({ id: mail.id, from: "host" }); expect(mailCredentials.smtpUrl).toMatch(/^smtp:\/\//u); const client = yield* HttpClient.HttpClient; const response = yield* client.get(`${restUrl}/factory_rows`); @@ -639,25 +619,29 @@ it.effect("validates Supabase composition recipes before creating instances", () }, endpoints: { sql: { port: "auto" as const } }, }; - const duplicate = yield* owner.composition.supabase([database, database]).pipe(Effect.flip); - expect(duplicate.operation).toBe("supabase"); - expect(yield* owner.services.list).toHaveLength(0); - const conflicting = yield* owner.composition - .supabase([ - { - service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: 41_001 } }, - }, - { - service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: 41_002 } }, - }, - ]) + const duplicate = yield* owner.rpc + .supabaseComposition({ services: [database, database] }) .pipe(Effect.flip); - expect(conflicting.operation).toBe("supabase"); - expect(yield* owner.services.list).toHaveLength(0); + expect(duplicate.operation).toBe("supabaseComposition"); + expect((yield* state.read(stack.id))?.instances).toHaveLength(0); + const conflicting = yield* owner.rpc + .supabaseComposition({ + services: [ + { + service: "rest", + config: { databaseUrl: "postgresql://placeholder" }, + endpoints: { http: { port: 41_001 } }, + }, + { + service: "auth", + config: { databaseUrl: "postgresql://placeholder" }, + endpoints: { http: { port: 41_002 } }, + }, + ], + }) + .pipe(Effect.flip); + expect(conflicting.operation).toBe("supabaseComposition"); + expect((yield* state.read(stack.id))?.instances).toHaveLength(0); const badDataRoot = `${root}/data-file`; const badOwner = yield* ownerFor({ @@ -670,29 +654,129 @@ it.effect("validates Supabase composition recipes before creating instances", () yield* fs.makeDirectory(badDataRoot); yield* fs.remove(badDataRoot, { recursive: true }); yield* fs.writeFileString(badDataRoot, "occupied"); - const registrationFailure = yield* badOwner.services.create(database).pipe(Effect.flip); - expect(registrationFailure.operation).toBe("register"); - expect(yield* badOwner.services.list).toHaveLength(0); + const registrationFailure = yield* badOwner.rpc.createService(database).pipe(Effect.flip); + expect(registrationFailure.operation).toBe("createService"); expect((yield* state.read(stack.id))?.instances).toHaveLength(0); - const databaseWithoutSql = yield* owner.services.create({ + const databaseWithoutSql = yield* owner.rpc.createService({ service: "database", config: database.config, endpoints: {}, }); - expect(yield* owner.credentials(databaseWithoutSql.id, "host")).toEqual({}); + expect(yield* owner.rpc.credentials({ id: databaseWithoutSql.id, from: "host" })).toEqual({}); - const missingSql = yield* owner.composition - .supabase([ - { ...database, endpoints: {} }, - { - service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: "auto" } }, - }, - ]) + const missingSql = yield* owner.rpc + .supabaseComposition({ + services: [ + { ...database, endpoints: {} }, + { + service: "rest", + config: { databaseUrl: "postgresql://placeholder" }, + endpoints: { http: { port: "auto" } }, + }, + ], + }) .pipe(Effect.flip); expect(missingSql.message).toBe("database requires configured sql endpoint"); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.effect("lets a retry choose other credentials after the first database creation failed", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-owner-credential-rollback-", + }); + const stack = initial("owner-credential-rollback"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const database = (password: string) => ({ + service: "database" as const, + config: { version: "17", databasePassword: Redacted.make(password), jwtExpiry: 3600 }, + endpoints: {}, + }); + const badDataRoot = `${root}/data-file`; + yield* fs.writeFileString(badDataRoot, "occupied"); + const failing = yield* ownerFor({ saved: stack, state, root: badDataRoot, cacheRoot }); + yield* Effect.addFinalizer(() => failing.namespace.destroy.pipe(Effect.ignore)); + + yield* failing.rpc.createService(database("first-password")).pipe(Effect.flip); + + expect((yield* state.read(stack.id))?.credentials).toBeUndefined(); + const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + yield* owner.rpc.createService(database("second-password")); + expect((yield* state.read(stack.id))?.credentials?.databasePassword).toBe("second-password"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.effect("rejects a duplicate instance without releasing the existing instance's claims", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-duplicate-" }); + const state = yield* stateFor(`${root}/state`); + const creation = yield* Schema.decodeEffect(ServiceCreation)({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + const instance = { id: "mail", creation }; + const claim = { key: "mail:http", host: "127.0.0.1", port: 54_321 }; + const stack: SavedStack = { + ...initial("owner-duplicate"), + instances: [instance], + ports: [claim], + }; + yield* state.save(stack); + + const failure = yield* ownerFor({ + saved: { ...stack, instances: [instance, instance] }, + state, + root: `${root}/data`, + cacheRoot, + }).pipe(Effect.flip); + + expect(failure.message).toBe("Duplicate instance mail"); + expect((yield* state.read(stack.id))?.ports).toEqual([claim]); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.effect("refuses to generate credentials for a stack whose saved instances consume them", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-credentials-" }); + const stack = initial("owner-credentials"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + yield* owner.rpc.createService({ + service: "database", + config: { version: "17", jwtExpiry: 3600 }, + endpoints: {}, + }); + const withCredentials = yield* state.read(stack.id); + if (withCredentials?.credentials === undefined) + return yield* Effect.die("database creation did not save credentials"); + const { credentials: _, ...withoutCredentials } = withCredentials; + yield* state.save(withoutCredentials); + + const refused = yield* owner.rpc + .createService({ service: "auth", config: { databaseUrl: "postgresql://placeholder" } }) + .pipe(Effect.flip); + + expect(refused.message).toBe( + "Saved instances have no stack credential record; refusing to infer credentials", + ); + const current = yield* state.read(stack.id); + expect(current?.credentials).toBeUndefined(); + expect(current?.instances).toHaveLength(1); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index c5a4b86d81..c413687f12 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -1,72 +1,70 @@ import { Context, Crypto, - Data, Effect, + Fiber, FileSystem, + Layer, + Option, Path, - Redacted, Ref, - Scope, Schema, + Scope, Semaphore, Stream, - Layer, } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { failureMessage } from "./internal/failure-message.ts"; import * as Network from "./Network.ts"; -import * as Container from "./runtime/Container.ts"; -import type { NetworkBinding, NetworkNamespace } from "./Network.ts"; +import type { NetworkEndpoint, NetworkNamespace } from "./Network.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { CompositionConfig, RegisteredInstance } from "./Orchestrator.ts"; +import type { CompositionConfig } from "./Orchestrator.ts"; import { makeService, ServiceError, - ServiceLaunchError, + type ServiceAdmission, type ServiceInstance, + type ServiceInstanceContext, type ServiceObservation, } from "./Service.ts"; -import { ProxyError, type BackendAddress } from "./Proxy.ts"; -import type { NetworkEndpoint } from "./Network.ts"; +import { ProxyError } from "./Proxy.ts"; import { - apiRoute, + backendAddress, credentialsFor, endpointNames, endpointPort, - EndpointError, outputsFor, publicUrl, + sharedRoutes, } from "./host/Endpoints.ts"; +import { + consumesCredentials, + CredentialError, + credentialOverrides, + nextCredentials, + withCredentials, + withoutUnusedCredentials, +} from "./host/Credentials.ts"; import { makeSupabaseComposition, - SupabaseCompositionError, type SupabaseCompositionOptions, } from "./composition/Supabase.ts"; import { makeServiceRecipe, ServiceCreation, - ServiceCreationInput, - type CatalogLog, - type CatalogRecipe, serviceSchemas, + type CatalogRecipe, + type ServiceCreationInput, } from "./services/Catalog.ts"; +import type { CatalogError } from "./services/Recipe.ts"; +import * as Container from "./runtime/Container.ts"; +import { stackError, type OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; -import type { SavedInstance, SavedStack, StackCredentials } from "./State.ts"; -import { resolveStackIdentity } from "./services/ServiceConfig.ts"; -import { - DEFAULT_LOCAL_DATABASE_PASSWORD, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_POSTGRES_ROOT_KEY, -} from "./Defaults.ts"; +import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; import { makeDockerHelperRegistry } from "./storage/DockerHelperRegistry.ts"; -export class OwnerError extends Data.TaggedError("OwnerError")<{ - readonly operation: string; - readonly message: string; - readonly cause?: unknown; -}> {} - export interface OwnerOptions { readonly saved: SavedStack; readonly state: State.Interface; @@ -74,1376 +72,634 @@ export interface OwnerOptions { readonly cacheRoot: string; } -export class Service extends Context.Service()("@supabase/stack/Owner") {} +type OwnerRpcs = RpcGroup.Rpcs; -type OwnerObservation = ServiceObservation & { - readonly endpoints: ReadonlyArray; +/** RPC handlers for the owner's instance and composition operations. */ +type Handlers = { + readonly [Current in OwnerRpcs as Current["_tag"]]: ( + payload: Rpc.Payload, + ) => Rpc.ResultFrom; }; +/** Removes containers labeled with this stack and data root; native stacks own none. */ +export const sweepContainers = Effect.fn("Owner.sweepContainers")(function* ( + saved: Pick, + root: string, +) { + if (saved.runtime !== "native") + yield* Container.removeStackContainers({ engine: saved.runtime, stackId: saved.id, root }); +}); + +type NamespaceError = + | Orchestrator.OrchestratorError + | Orchestrator.LifecycleError + | Network.NetworkError + | State.StateError + | Effect.Error>; + export interface Interface { - readonly services: { - readonly create: ( - creation: unknown, - ) => Effect.Effect<{ id: string; creation: ServiceCreation }, OwnerError>; - readonly get: ( - id: string, - ) => Effect.Effect<{ id: string; creation: ServiceCreation }, OwnerError>; - readonly list: Effect.Effect< - ReadonlyArray<{ id: string; creation: ServiceCreation }>, - OwnerError - >; - }; - readonly core: { - readonly get: (id: string) => Effect.Effect; - readonly status: (id: string) => Effect.Effect; - readonly followStatus: (id: string) => Stream.Stream; - readonly prepare: (id: string) => Effect.Effect; - readonly logs: (id: string) => Stream.Stream; - readonly start: (id: string) => Effect.Effect; - readonly ready: (id: string) => Effect.Effect; - readonly stop: (id: string) => Effect.Effect; - readonly restart: (id: string, creation?: unknown) => Effect.Effect; - readonly destroy: (id: string) => Effect.Effect; - }; - readonly composition: { - readonly supabase: ( - creations: ReadonlyArray, - options?: SupabaseCompositionOptions, - ) => Effect.Effect, OwnerError>; - readonly configure: (configuration: CompositionConfig) => Effect.Effect; - readonly get: Effect.Effect; - readonly start: Effect.Effect, OwnerError>; - readonly stop: Effect.Effect, OwnerError>; - readonly restart: Effect.Effect, OwnerError>; - }; - readonly credentials: ( - id: string, - from: "host" | "runtime", - ) => Effect.Effect>, OwnerError>; - readonly getStackCredentials: Effect.Effect; - readonly snapshots: { - readonly saveSnapshot: (id: string, key: string) => Effect.Effect; - readonly restoreSnapshot: (id: string, key: string) => Effect.Effect; - readonly resetData: (id: string) => Effect.Effect; - }; + readonly handlers: Handlers; + readonly getStackCredentials: Effect.Effect; readonly namespace: { - readonly stop: Effect.Effect; - readonly destroy: Effect.Effect; + /** Stops every instance after in-flight definition changes settle, then removes containers. */ + readonly stop: Effect.Effect; + /** + * Destroys every instance once in-flight definition changes settle, then releases owned + * containers, claims and saved state. + */ + readonly destroy: Effect.Effect; }; - readonly setDraining: (draining: boolean) => Effect.Effect; - readonly getServing: Effect.Effect; + readonly setDraining: (draining: boolean) => Effect.Effect; + readonly getServing: Effect.Effect; } -const errorFor = (operation: string, cause: unknown) => - new OwnerError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +export class Service extends Context.Service()("@supabase/stack/Owner") {} -const supabaseError = (cause: unknown) => - cause instanceof SupabaseCompositionError - ? cause - : new SupabaseCompositionError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +interface Entry extends Orchestrator.RegisteredInstance { + readonly core: ServiceInstance; + readonly recipe: CatalogRecipe; + /** The saved creation, which composition wiring and launches update. */ + readonly creation: Ref.Ref; + readonly namespace: NetworkNamespace; +} const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -const consumesCredentials = (creation: unknown): boolean => { - if (!isRecord(creation) || typeof creation.service !== "string") return true; - if (creation.service === "database") return true; - if (["auth", "realtime", "storage", "functions", "studio", "pooler"].includes(creation.service)) - return true; - if (creation.service !== "rest") return false; - const config = isRecord(creation.config) ? creation.config : {}; - return config.jwks === undefined || config.jwtSecret !== undefined; -}; +const serviceError = + (operation: string) => + (cause: unknown): ServiceError => + new ServiceError({ operation, message: failureMessage(cause), cause }); -const credentialOverridesFor = (creation: ServiceCreationInput): Record => { - const overrides: Record = {}; - if (creation.service === "database") { - if (creation.config.jwtSecret !== undefined) - overrides.jwtSecret = Redacted.value(creation.config.jwtSecret); - if (creation.config.rootKey !== undefined) - overrides.postgresRootKey = Redacted.value(creation.config.rootKey); - if (creation.config.databasePassword !== undefined) - overrides.databasePassword = Redacted.value(creation.config.databasePassword); - } else if ("jwtSecret" in creation.config && creation.config.jwtSecret !== undefined) { - overrides.jwtSecret = creation.config.jwtSecret; - } - return overrides; -}; +const rpcError = (operation: string) => + Effect.mapError((cause: unknown) => stackError(operation, cause)); -const clearUnusedCredentials = (current: SavedStack): SavedStack => { - if ( - current.credentials === undefined || - current.instances.some(({ creation }) => consumesCredentials(creation)) - ) - return current; - const withoutCredentials = { ...current }; - delete withoutCredentials.credentials; - return withoutCredentials; -}; - -const creationJson = Schema.toCodecJson(ServiceCreation); -const encodeCreation = (creation: ServiceCreation) => Schema.encodeEffect(creationJson)(creation); - -const makeOwnerWithDependencies = ( - options: OwnerOptions, - orchestrator: Orchestrator.Interface, - network: Network.Interface, -) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const http = yield* HttpClient.HttpClient; - const ownerScope = yield* Scope.Scope; - const helperOwnerId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("identity", cause)), - ); - const helpers = yield* makeDockerHelperRegistry(helperOwnerId); - const recipes = yield* Ref.make(new Map()); - const instances = yield* Ref.make(new Map>()); - const namespaces = yield* Ref.make(new Map()); - const composition = yield* Ref.make({ - members: [], - dependencies: [], - }); - const registryGate = yield* Semaphore.make(1); - const compositionGate = yield* Semaphore.make(1); - const draining = yield* Ref.make(false); - const runtime = options.saved.runtime; +const mergeInputs = (creation: ServiceCreation, inputs: Record) => + Schema.decodeUnknownEffect(ServiceCreation)({ + ...creation, + config: Object.fromEntries( + Object.entries({ ...creation.config, ...inputs }).filter(([, value]) => value !== undefined), + ), + }).pipe(Effect.mapError(serviceError("inputs"))); - const updateState = Effect.fn("Owner.updateState")(function* ( - update: (current: SavedStack) => Effect.Effect, - ) { - return yield* options.state - .withLock( - Effect.gen(function* () { - const current = yield* options.state - .read(options.saved.id) - .pipe(Effect.mapError((cause) => errorFor("state", cause))); - if (current === undefined) return yield* errorFor("state", "Saved stack is missing"); - const next = yield* update(current); - yield* options.state - .save(next) - .pipe(Effect.mapError((cause) => errorFor("state", cause))); - return next; - }), - ) - .pipe( - Effect.mapError((cause) => - cause instanceof OwnerError ? cause : errorFor("state", cause), - ), - ); - }); +const restartCreation = (creation: ServiceCreation, candidate: unknown) => + candidate === undefined + ? Effect.succeed(creation) + : Schema.decodeUnknownEffect(ServiceCreation)({ + ...creation, + config: isRecord(candidate) && "config" in candidate ? candidate.config : candidate, + }).pipe(Effect.mapError(serviceError("restart"))); + +const withoutInstance = (current: SavedStack, id: string): SavedStack => + withoutUnusedCredentials({ + ...current, + instances: current.instances.filter((instance) => instance.id !== id), + composition: { + members: current.composition.members.filter((member) => member.id !== id), + dependencies: current.composition.dependencies.filter( + (dependency) => dependency.from !== id && dependency.to !== id, + ), + }, + }); - const persistCreation = Effect.fn("Owner.persistCreation")(function* ( - id: string, - creation: ServiceCreation, - ) { - const encoded = yield* encodeCreation(creation).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - ); - yield* updateState((current) => - Effect.succeed({ - ...current, - instances: current.instances.map((instance) => - instance.id === id ? { ...instance, creation: encoded } : instance, - ), - }), - ); - yield* Ref.update(recipes, (values) => { - const recipe = values.get(id); - if (recipe === undefined) return values; - return new Map(values).set(id, { ...recipe, creation }); - }); - }); +const drainingBlocks: ReadonlyArray = ["start", "arm", "restart", "storage"]; + +const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { + const services = yield* Effect.context< + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | Scope.Scope + >(); + const ownerScope = Context.get(services, Scope.Scope); + const crypto = Context.get(services, Crypto.Crypto); + const network = yield* Network.Service; + const orchestrator = yield* Orchestrator.make(); + const helpers = yield* makeDockerHelperRegistry(yield* crypto.randomUUIDv4); + const definitionGate = yield* Semaphore.make(1); + const draining = yield* Ref.make(false); + const { id: stackId, runtime } = options.saved; + const routeKeys = { + publishableKey: options.saved.credentials?.publishableKey ?? "", + secretKey: options.saved.credentials?.secretKey ?? "", + anonKey: options.saved.credentials?.anonKey ?? "", + serviceRoleKey: options.saved.credentials?.serviceRoleKey ?? "", + }; - const addCreation = Effect.fn("Owner.addCreation")(function* ( - id: string, - creation: ServiceCreation, - ) { - const encoded = yield* encodeCreation(creation).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - ); - yield* updateState((current) => - Effect.succeed({ - ...current, - instances: [...current.instances, { id, creation: encoded } satisfies SavedInstance], - }), - ); - }); + const rejectWhileDraining = Ref.get(draining).pipe( + Effect.flatMap((isDraining) => + isDraining + ? Effect.fail(new ServiceError({ operation: "draining", message: "Owner is draining" })) + : Effect.void, + ), + ); - const routeKeys = { - publishableKey: options.saved.credentials?.publishableKey ?? "", - secretKey: options.saved.credentials?.secretKey ?? "", - anonKey: options.saved.credentials?.anonKey ?? "", - serviceRoleKey: options.saved.credentials?.serviceRoleKey ?? "", - }; - const resolveStackCredentials = Effect.fn("Owner.resolveStackCredentials")(function* ( - overrides: Record, - identity?: State.StackIdentityInput, - ) { - const credentials = yield* options.state - .withLock( - Effect.gen(function* () { - const current = yield* options.state - .read(options.saved.id) - .pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - if (current === undefined) - return yield* errorFor("credentials", "Saved stack is missing"); - const saved = current.credentials; - if (saved === undefined) { - const hasCredentialConsumer = current.instances.some(({ creation: value }) => - consumesCredentials(value), - ); - if (hasCredentialConsumer) - return yield* errorFor( - "credentials", - "Saved instances have no stack credential record; refusing to infer credentials", - ); - const jwtSecret = overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET; - const resolvedIdentity = yield* resolveStackIdentity(jwtSecret, identity, undefined); - const initial: StackCredentials = { - jwtSecret, - postgresRootKey: overrides.postgresRootKey ?? DEFAULT_POSTGRES_ROOT_KEY, - databasePassword: overrides.databasePassword ?? DEFAULT_LOCAL_DATABASE_PASSWORD, - ...resolvedIdentity, - }; - yield* options.state - .save({ ...current, credentials: initial }) - .pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - return initial; - } - const conflict = - (overrides.postgresRootKey !== undefined && - overrides.postgresRootKey !== saved.postgresRootKey && - "rootKey") || - (overrides.databasePassword !== undefined && - overrides.databasePassword !== saved.databasePassword && - "databasePassword") || - (identity === undefined && - overrides.jwtSecret !== undefined && - overrides.jwtSecret !== saved.jwtSecret && - "jwtSecret"); - if (conflict !== false && conflict !== undefined) - return yield* errorFor( - "credentials", - `Credential override ${conflict} conflicts with the saved stack value`, - ); - const jwtSecret = - identity === undefined - ? saved.jwtSecret - : (overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET); - const resolvedIdentity = yield* resolveStackIdentity(jwtSecret, identity, saved); - const next: StackCredentials = { - ...saved, - jwtSecret, - ...resolvedIdentity, - }; - const identityChanged = - next.jwtSecret !== saved.jwtSecret || - next.publishableKey !== saved.publishableKey || - next.secretKey !== saved.secretKey || - next.anonKey !== saved.anonKey || - next.serviceRoleKey !== saved.serviceRoleKey || - next.jwks !== saved.jwks || - next.gotrueJwtKeys !== saved.gotrueJwtKeys || - next.remoteJwks !== saved.remoteJwks || - next.anonKeyIsOverride !== saved.anonKeyIsOverride || - next.serviceRoleKeyIsOverride !== saved.serviceRoleKeyIsOverride; - if (identityChanged) { - const savedComposition = yield* Schema.decodeUnknownEffect( - Orchestrator.CompositionConfig, - )(current.composition).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - ); - const memberIds = new Set([ - ...savedComposition.members.map(({ id }) => id), - ...savedComposition.dependencies.flatMap(({ from, to }) => [from, to]), - ]); - for (const id of memberIds) { - const { lifecycle, wakeEnabled } = yield* observation(id).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - ); - if (lifecycle !== "stopped" || wakeEnabled) - return yield* errorFor( - "credentials", - `Service ${id} must be stopped with wake disabled before identity changes`, - ); - } - yield* options.state - .save({ ...current, credentials: next }) - .pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - } - return next; - }), - ) - .pipe( - Effect.mapError((cause) => - cause instanceof OwnerError ? cause : errorFor("credentials", cause), + // Mask only the permit handoff; admitted definition changes belong to the owner scope. A change + // arriving while draining fails before waiting behind the shutdown that holds the permit. + const definitionChange = (work: Effect.Effect) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* rejectWhileDraining; + yield* restore(definitionGate.take(1)); + const fiber = yield* Effect.forkIn( + rejectWhileDraining.pipe( + Effect.andThen(work), + Effect.ensuring(definitionGate.release(1)), ), + ownerScope, + { uninterruptible: false }, ); - Object.assign(routeKeys, credentials); - return credentials; - }); + return yield* restore(Fiber.join(fiber)); + }), + ); - const getStackCredentials = options.state.read(options.saved.id).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - Effect.flatMap((current) => - current === undefined - ? errorFor("credentials", "Saved stack is missing") - : current.credentials === undefined - ? errorFor("credentials", "Stack credentials have not been established") - : Effect.succeed(current.credentials), + const readSaved = options.state + .read(stackId) + .pipe( + Effect.flatMap((saved) => + saved === undefined + ? Effect.fail( + new State.StateError({ operation: "read", message: "Saved stack is missing" }), + ) + : Effect.succeed(saved), ), - Effect.withSpan("Owner.getStackCredentials"), + ); + const updateState = (update: (current: SavedStack) => SavedStack) => + options.state.withLock( + readSaved.pipe(Effect.flatMap((current) => options.state.save(update(current)))), ); - const resolveCredentials = Effect.fn("Owner.resolveCredentials")(function* (input: unknown) { - const creation = yield* Schema.decodeUnknownEffect(ServiceCreationInput)(input).pipe( - Effect.mapError((cause) => errorFor("config", cause)), - ); - if (!consumesCredentials(creation)) - return yield* Schema.decodeUnknownEffect(ServiceCreation)(creation).pipe( - Effect.mapError((cause) => errorFor("config", cause)), - ); - const overrides = credentialOverridesFor(creation); - const credentials = yield* resolveStackCredentials(overrides); - - const config = { ...creation.config }; - if (creation.service === "database") { - Object.assign(config, { - databasePassword: Redacted.make(credentials.databasePassword), - jwtSecret: Redacted.make(credentials.jwtSecret), - rootKey: Redacted.make(credentials.postgresRootKey), - }); - } else { - Object.assign(config, { jwtSecret: credentials.jwtSecret }); - switch (creation.service) { - case "auth": - Object.assign(config, { - gotrueJwtKeys: creation.config.gotrueJwtKeys ?? credentials.gotrueJwtKeys, - }); - break; - case "rest": - case "realtime": - Object.assign(config, { - jwks: creation.config.jwks ?? credentials.jwks, - }); - break; - case "storage": - Object.assign(config, { - jwks: creation.config.jwks ?? credentials.jwks, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - }); - break; - case "functions": - Object.assign(config, { - jwks: creation.config.jwks ?? credentials.jwks, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - publishableKey: creation.config.publishableKey ?? credentials.publishableKey, - secretKey: creation.config.secretKey ?? credentials.secretKey, - }); - break; - case "studio": - Object.assign(config, { - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - publishableKey: creation.config.publishableKey ?? credentials.publishableKey, - secretKey: creation.config.secretKey ?? credentials.secretKey, - }); - break; - default: - break; - } - } - return yield* Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - config, - }).pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - }); - - const removeCreation = (id: string) => - updateState((current) => - Effect.succeed( - clearUnusedCredentials({ - ...current, - instances: current.instances.filter((instance) => instance.id !== id), - }), - ), - ).pipe(Effect.asVoid); - - const persistComposition = (value: CompositionConfig) => - updateState((current) => Effect.succeed({ ...current, composition: value })).pipe( - Effect.asVoid, - ); - const clearCredentialsIfUnused = () => - updateState((current) => { - return Effect.succeed(clearUnusedCredentials(current)); - }).pipe(Effect.asVoid); - const removeInstance = (id: string) => { - const prune = (current: CompositionConfig): CompositionConfig => ({ - members: current.members.filter((member) => member.id !== id), - dependencies: current.dependencies.filter( - (dependency) => dependency.from !== id && dependency.to !== id, - ), - }); - return updateState((current) => - Schema.decodeUnknownEffect(Orchestrator.CompositionConfig)(current.composition).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - Effect.map((savedComposition) => - clearUnusedCredentials({ - ...current, - instances: current.instances.filter((instance) => instance.id !== id), - composition: prune(savedComposition), - }), + const requireStopped = (configuration: CompositionConfig) => + Effect.forEach( + new Set([ + ...configuration.members.map(({ id }) => id), + ...configuration.dependencies.flatMap(({ from, to }) => [from, to]), + ]), + (id) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => entry.core.get), + Effect.flatMap(({ lifecycle, wakeEnabled }) => + lifecycle === "stopped" && !wakeEnabled + ? Effect.void + : Effect.fail( + new CredentialError({ + message: `Service ${id} must be stopped with wake disabled before identity changes`, + }), + ), ), ), - ).pipe( - Effect.tap(() => Ref.update(composition, prune)), - Effect.asVoid, - ); - }; + { discard: true }, + ); - const recipeFor = (input: unknown, id: string) => - makeServiceRecipe(input, { - stackId: options.saved.id, - instanceId: id, - root: options.root, - cacheRoot: options.cacheRoot, - runtime, - helpers, - }).pipe(Effect.mapError((cause) => errorFor("recipe", cause))); - const recipeReady = (input: unknown, id: string) => - recipeFor(input, id).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(Scope.Scope, ownerScope), - ); + const resolveStackCredentials = Effect.fn("Owner.resolveStackCredentials")(function* ( + overrides: Effect.Success>, + identity?: StackIdentityInput, + ) { + const credentials = yield* options.state.withLock( + Effect.gen(function* () { + const current = yield* readSaved; + const next = yield* nextCredentials(current, overrides, identity); + if (next === current.credentials) return next; + if (current.credentials !== undefined) yield* requireStopped(current.composition); + yield* options.state.save({ ...current, credentials: next }); + return next; + }), + ); + Object.assign(routeKeys, credentials); + return credentials; + }); - const getRecipe = (id: string) => - Ref.get(recipes).pipe( - Effect.flatMap((values) => { - const recipe = values.get(id); - return recipe === undefined - ? Effect.fail(errorFor("get", `Unknown service ${id}`)) - : Effect.succeed(recipe); - }), - ); + const resolveCredentials = Effect.fn("Owner.resolveCredentials")(function* ( + creation: ServiceCreationInput, + ) { + if (!consumesCredentials(creation)) + return yield* Schema.decodeUnknownEffect(ServiceCreation)(creation); + const credentials = yield* resolveStackCredentials(yield* credentialOverrides([creation])); + return yield* withCredentials(creation, credentials); + }); - const getCreation = (id: string) => getRecipe(id).pipe(Effect.map((recipe) => recipe.creation)); + const recipeFor = (creation: ServiceCreation, id: string) => + makeServiceRecipe(creation, { + stackId, + instanceId: id, + root: options.root, + cacheRoot: options.cacheRoot, + runtime, + helpers, + }).pipe(Effect.provideContext(services)); + + const persistCreation = (entry: Pick, creation: ServiceCreation) => + updateState((current) => ({ + ...current, + instances: current.instances.map((instance) => + instance.id === entry.id ? { ...instance, creation } : instance, + ), + })).pipe(Effect.andThen(Ref.set(entry.creation, creation))); - const mergeInputs = (creation: ServiceCreation, inputs: Record) => - Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - config: Object.fromEntries( - Object.entries({ ...creation.config, ...inputs }).filter( - ([, value]) => value !== undefined, + const register = Effect.fn("Owner.register")(function* (id: string, recipe: CatalogRecipe) { + if (Option.isSome(yield* orchestrator.get(id).pipe(Effect.option))) + return yield* new Orchestrator.OrchestratorError({ + operation: "register", + message: `Duplicate instance ${id}`, + }); + const initial = recipe.creation; + const creation = yield* Ref.make(initial); + const namespaceRef = yield* Ref.make(undefined); + const core = yield* makeService( + { + ...recipe.definition, + launch: (context) => + persistCreation({ id, creation }, context.config).pipe( + Effect.mapError(serviceError("state")), + Effect.andThen(recipe.definition.launch(context)), ), - ), - }).pipe( - Effect.mapError( - (cause) => new ServiceError({ operation: "inputs", message: String(cause) }), - ), - ); - - const restartCreation = ( - creation: ServiceCreation, - candidate: unknown, - ): Effect.Effect => { - if (candidate === undefined) return Effect.succeed(creation); - const config = isRecord(candidate) && "config" in candidate ? candidate.config : candidate; - return Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - config, - }).pipe( - Effect.mapError( - (cause) => new ServiceError({ operation: "restart", message: String(cause) }), - ), - ); - }; - - const register = Effect.fn("Owner.register")(function* (id: string, recipe: CatalogRecipe) { - const initial = recipe.creation; - const namespaceRef = yield* Ref.make(undefined); - const instance = yield* makeService( - { - ...recipe.definition, - launch: (context) => - persistCreation(id, context.config).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "state", - message: cause.message, - cause, - }), - ), - Effect.andThen(recipe.definition.launch(context)), - Effect.map((session) => ({ - ...session, - remove: session.remove.pipe( - Effect.andThen( - Ref.get(namespaceRef).pipe( - Effect.flatMap((value) => value?.close ?? Effect.void), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "close", - message: cause.message, - cause, - }), - ), - ), - ), - ), - })), - Effect.catchTag("ServiceLaunchError", (failure) => - Effect.fail( - new ServiceLaunchError({ - failure: failure.failure, - runtime: { - ...failure.runtime, - remove: failure.runtime.remove.pipe( - Effect.andThen( - Ref.get(namespaceRef).pipe( - Effect.flatMap((value) => value?.close ?? Effect.void), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "close", - message: cause.message, - cause, - }), - ), - ), - ), - ), - }, - }), - ), + removeData: (context) => + recipe.definition.removeData(context).pipe( + Effect.andThen( + Ref.get(namespaceRef).pipe( + Effect.flatMap((namespace) => namespace?.release ?? Effect.void), + Effect.mapError(serviceError("release")), ), ), - removeData: (context) => - recipe.definition.removeData(context).pipe( - Effect.andThen( - Ref.get(namespaceRef).pipe( - Effect.flatMap((value) => value?.release ?? Effect.void), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "release", - message: cause.message, - cause, - }), - ), - ), - ), - Effect.andThen( - removeInstance(id).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "state", - message: cause.message, - cause, - }), - ), - ), - ), - ), - }, - { - id, - config: initial, - coordinate: (operation, transition) => - Ref.get(draining).pipe( - Effect.flatMap((isDraining) => - isDraining && ["start", "arm", "restart", "storage"].includes(operation) - ? Effect.fail( - new ServiceError({ - operation: "draining", - message: "Owner is draining", - }), - ) - : orchestrator.admissionFor(id)(operation, transition), - ), - ), - }, - ); - const enabled = instance.get.pipe( - Effect.map( - (observation) => - observation.registered && - observation.lifecycle !== "stopped" && - !(observation.exit !== undefined && !observation.wakeEnabled), - ), - ); - const endpointEntries = Object.fromEntries( - endpointNames(initial).map((name) => { - const route = name === "http" ? apiRoute(initial.service) : undefined; - const endpoint: NetworkEndpoint = { - protocol: name === "http" ? ("http" as const) : ("tcp" as const), - port: endpointPort(initial, name), - backend: orchestrator.acquire(id, name !== "inspector").pipe( - Effect.flatMap(() => recipe.endpoint(name)), - Effect.flatMap((address): Effect.Effect => { - if (address.kind === "unix") { - return address.path === undefined - ? Effect.fail( - new ProxyError({ - message: "Unix endpoint has no path", - }), - ) - : Effect.succeed({ - path: `${address.path}/.s.PGSQL.${address.port}`, - }); - } - return Effect.succeed({ - host: address.host ?? "127.0.0.1", - port: address.port, - }); - }), - Effect.mapError((cause) => - cause instanceof ProxyError - ? cause - : new ProxyError({ message: String(cause), cause }), + Effect.andThen( + updateState((current) => withoutInstance(current, id)).pipe( + Effect.mapError(serviceError("state")), ), ), - enabled, - ...(route === undefined - ? {} - : { - shared: - initial.service === "realtime" - ? [ - { - prefix: "/realtime/v1/api", - upstreamPrefix: "/api", - upstreamHost: "realtime-dev", - keyRewrite: { policy: "bearer" as const, keys: routeKeys }, - }, - { - prefix: route, - upstreamPrefix: "/socket", - upstreamHost: "realtime-dev", - keyRewrite: { policy: "query" as const, keys: routeKeys }, - }, - ] - : initial.service === "storage" - ? [ - { - prefix: `${route}/s3`, - upstreamPrefix: "/s3", - }, - { - prefix: route, - upstreamPrefix: "/", - keyRewrite: { policy: "bearer" as const, keys: routeKeys }, - }, - ] - : [ - { - prefix: route, - upstreamPrefix: "/", - ...(initial.service === "rest" || initial.service === "auth" - ? { keyRewrite: { policy: "bearer" as const, keys: routeKeys } } - : initial.service === "functions" - ? { - keyRewrite: { - policy: "sb-api-key" as const, - keys: routeKeys, - }, - } - : {}), - }, - ], - }), - }; - return [name, endpoint]; - }), - ); - const namespace = yield* network - .register({ id, endpoints: endpointEntries }) - .pipe(Effect.mapError((cause) => errorFor("network", cause))); - yield* Ref.set(namespaceRef, namespace); - const endpointAddress = (name: string, from: "host" | "runtime") => - namespace.address(name, from).pipe( - Effect.mapError( - (cause) => - new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - const databasePassword = () => - getCreation(id).pipe( - Effect.flatMap((creation) => - creation.service === "database" - ? Effect.succeed(Redacted.value(creation.config.databasePassword)) - : Effect.fail(new EndpointError({ message: "Output requires a database" })), - ), - Effect.mapError((cause) => - cause instanceof EndpointError - ? cause - : new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), ), - ); - const outputs = Object.fromEntries( - Object.entries(outputsFor(initial, endpointAddress, databasePassword)).map( - ([name, value]) => [ - name, - value.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "output", - message: cause.message, - cause, - }), - ), - ), - ], - ), - ); - const registered: RegisteredInstance = { + }, + { id, - core: { - get: instance.get, - ready: instance.ready, - stop: instance.stop, - destroy: instance.destroy, - arm: instance.arm, - armAt: instance.armAt, - sleep: instance.sleep, - observation: instance.observation, - }, - startAt: (revision, inputs, wake, guard) => - getCreation(id).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "get", - message: cause.message, - cause, - }), - ), - Effect.flatMap((creation) => mergeInputs(creation, inputs)), - Effect.flatMap((candidate) => instance.startAt(revision, candidate, wake, guard)), + config: initial, + coordinate: (operation, transition) => + (drainingBlocks.includes(operation) ? rejectWhileDraining : Effect.void).pipe( + Effect.andThen(orchestrator.admissionFor(id)(operation, transition)), ), - restart: (revision, inputs, candidate, guard) => - getCreation(id).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "get", - message: cause.message, - cause, - }), + }, + ).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const enabled = core.get.pipe( + Effect.map( + (observation) => + observation.registered && + observation.lifecycle !== "stopped" && + !(observation.exit !== undefined && !observation.wakeEnabled), + ), + ); + const endpoints = Object.fromEntries( + endpointNames(initial).map((name) => { + const shared = sharedRoutes(initial, name, routeKeys); + const endpoint: NetworkEndpoint = { + protocol: name === "http" ? "http" : "tcp", + port: endpointPort(initial, name), + backend: orchestrator.acquire(id, name !== "inspector").pipe( + Effect.andThen(recipe.endpoint(name)), + Effect.flatMap(backendAddress), + Effect.mapError((cause) => + cause instanceof ProxyError + ? cause + : new ProxyError({ message: cause.message, cause }), ), - Effect.flatMap((creation) => restartCreation(creation, candidate)), - Effect.flatMap((creation) => mergeInputs(creation, inputs)), - Effect.flatMap((next) => instance.restart(next, revision, guard)), - ), - bind: namespace.bind.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "bind", - message: cause.message, - cause, - }), ), + enabled, + ...(shared === undefined ? {} : { shared }), + }; + return [name, endpoint]; + }), + ); + const namespace = yield* network.register({ id, endpoints }); + yield* Ref.set(namespaceRef, namespace); + const entry: Entry = { + id, + core, + recipe, + creation, + namespace, + startAt: (revision, inputs, wake, guard) => + Ref.get(creation).pipe( + Effect.flatMap((current) => mergeInputs(current, inputs)), + Effect.flatMap((candidate) => core.startAt(revision, candidate, wake, guard)), ), - close: namespace.close.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "close", - message: cause.message, - cause, - }), - ), - Effect.tap(() => - instance.get.pipe( - Effect.flatMap((observation) => - observation.registered - ? Effect.void - : Effect.all( - [ - Ref.update(recipes, (values) => { - const next = new Map(values); - next.delete(id); - return next; - }), - Ref.update(instances, (values) => { - const next = new Map(values); - next.delete(id); - return next; - }), - Ref.update(namespaces, (values) => { - const next = new Map(values); - next.delete(id); - return next; - }), - ], - { discard: true }, - ), - ), - ), - ), + restart: (revision, inputs, candidate, guard) => + Ref.get(creation).pipe( + Effect.flatMap((current) => restartCreation(current, candidate)), + Effect.flatMap((next) => mergeInputs(next, inputs)), + Effect.flatMap((next) => core.restart(next, revision, guard)), ), - hasEndpoint: endpointNames(initial).length > 0, - inputs: Object.keys(serviceSchemas[initial.service].fields), - outputs, - }; - yield* registryGate.withPermits(1)( - orchestrator.register(registered).pipe( - Effect.catch((cause) => namespace.release.pipe(Effect.andThen(Effect.fail(cause)))), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "register", - message: String(cause), - cause, - }), - ), + bind: namespace.bind.pipe(Effect.mapError(serviceError("bind"))), + close: namespace.close.pipe(Effect.mapError(serviceError("close"))), + hasEndpoint: endpointNames(initial).length > 0, + inputs: Object.keys(serviceSchemas[initial.service].fields), + outputs: Object.fromEntries( + Object.entries(outputsFor(initial, Ref.get(creation), namespace.address)).map( + ([name, output]) => [name, output.pipe(Effect.mapError(serviceError("output")))], ), - ); - yield* Ref.update(recipes, (values) => new Map(values).set(id, recipe)); - yield* Ref.update(instances, (values) => new Map(values).set(id, instance)); - yield* Ref.update(namespaces, (values) => new Map(values).set(id, namespace)); - }); - const registerReady = (id: string, recipe: CatalogRecipe) => - register(id, recipe).pipe(Effect.provideService(Scope.Scope, ownerScope)); - - for (const saved of options.saved.instances) { - const creation = yield* Schema.decodeUnknownEffect(creationJson)(saved.creation).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - ); - yield* registerReady(saved.id, yield* recipeReady(creation, saved.id)); - } - const savedComposition = yield* Schema.decodeUnknownEffect(Orchestrator.CompositionConfig)( - options.saved.composition, - ).pipe(Effect.mapError((cause) => errorFor("configure", cause))); + ), + }; yield* orchestrator - .configure(savedComposition) - .pipe(Effect.mapError((cause) => errorFor("configure", cause))); - yield* Ref.set(composition, savedComposition); + .register(entry) + .pipe(Effect.catch((cause) => namespace.release.pipe(Effect.andThen(Effect.fail(cause))))); + }); - const get = Effect.fn("Owner.get")(function* (id: string) { - return yield* getCreation(id).pipe( - Effect.map((creation) => ({ id, creation })), - Effect.mapError((cause) => errorFor("get", cause)), - ); - }); - const enrichObservation = (id: string, value: ServiceObservation) => - getCreation(id).pipe( - Effect.mapError((cause) => errorFor("status", cause)), - Effect.flatMap((creation) => - Ref.get(namespaces).pipe( - Effect.mapError((cause) => errorFor("status", cause)), - Effect.flatMap((values) => { - const namespace = values.get(id); - return namespace === undefined - ? Effect.fail(errorFor("status", "Service namespace is missing")) - : namespace.bindings.pipe( - Effect.map((endpoints) => ({ - ...value, - config: creation, - endpoints, - })), - Effect.mapError((cause) => errorFor("status", cause)), - ); - }), + for (const saved of options.saved.instances) + yield* register(saved.id, yield* recipeFor(saved.creation, saved.id)); + yield* orchestrator.configure(options.saved.composition); + + const removeSaved = (id: string) => updateState((current) => withoutInstance(current, id)); + + const addInstance = Effect.fn("Owner.addInstance")(function* (creation: ServiceCreation) { + const id = yield* crypto.randomUUIDv4; + const rollback = (cause: E) => removeSaved(id).pipe(Effect.andThen(Effect.fail(cause))); + const recipe = yield* recipeFor(creation, id); + yield* updateState((current) => ({ + ...current, + instances: [...current.instances, { id, creation }], + })).pipe(Effect.andThen(register(id, recipe)), Effect.catch(rollback), Effect.uninterruptible); + return { id, creation }; + }); + + type ComposeError = + | Effect.Error> + | Orchestrator.LifecycleError + | Network.NetworkError; + + const configure = (configuration: CompositionConfig) => + options.state.withLock( + orchestrator.configure( + configuration, + readSaved.pipe( + Effect.flatMap((current) => + options.state.save({ ...current, composition: configuration }), ), ), + ), + ); + + // A failed cleanup must not replace the failure that triggered it. + const clearUnusedCredentials = updateState(withoutUnusedCredentials).pipe( + Effect.catch((cause) => Effect.logWarning("Unused stack credentials were not cleared", cause)), + ); + const compose = Effect.fn("Owner.compose")( + function* ( + inputs: ReadonlyArray, + compositionOptions: SupabaseCompositionOptions, + ) { + yield* resolveStackCredentials( + yield* credentialOverrides(inputs), + compositionOptions.identity, ); - const observation = Effect.fn("Owner.status")(function* (id: string) { - return yield* orchestrator.get(id).pipe( - Effect.flatMap((instance) => instance.core.get), - Effect.flatMap((value) => enrichObservation(id, value)), - Effect.mapError((cause) => - cause instanceof OwnerError ? cause : errorFor("status", cause), - ), - ); - }); - const operation = ( - name: string, - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe(Effect.mapError((cause) => errorFor(name, cause))); - const storage = ( - id: string, - action: Effect.Effect, - ): Effect.Effect => - Ref.get(instances).pipe( - Effect.flatMap((values) => { - const instance = values.get(id); - return instance === undefined - ? Effect.fail(errorFor("storage", `Unknown service ${id}`)) - : instance - .storage( - action.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "storage", - message: String(cause), - }), + const creations = yield* Effect.forEach(inputs, resolveCredentials); + return yield* makeSupabaseComposition( + { + currentComposition: orchestrator.composition, + get: (id) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => Ref.get(entry.creation)), + Effect.map((creation) => ({ id, creation })), + ), + status: (id) => orchestrator.get(id).pipe(Effect.flatMap((entry) => entry.core.get)), + create: addInstance, + destroy: orchestrator.destroy, + bind: (id) => orchestrator.get(id).pipe(Effect.flatMap((entry) => entry.bind)), + address: (id, endpoint, from) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => entry.namespace.address(endpoint, from)), + Effect.map(({ host, port }) => publicUrl(host, port)), + ), + output: (id, name) => + orchestrator + .get(id) + .pipe( + Effect.flatMap( + (entry) => + entry.outputs[name] ?? + Effect.fail( + new ServiceError({ operation: "output", message: `Missing output ${name}` }), ), - ), - ) - .pipe(Effect.mapError((cause) => errorFor("storage", cause))); - }), - ); - const createService = Effect.fn("Owner.createService")(function* (input: unknown) { - yield* Ref.get(draining).pipe( - Effect.flatMap((isDraining) => - isDraining ? Effect.fail(errorFor("create", "Owner is draining")) : Effect.void, - ), - ); - const creation = yield* resolveCredentials(input); - const id = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("identity", cause)), - ); - yield* addCreation(id, creation); - yield* Effect.gen(function* () { - const recipe = yield* recipeReady(creation, id); - yield* registerReady(id, recipe); - }).pipe( - Effect.catch((cause) => removeCreation(id).pipe(Effect.andThen(Effect.fail(cause)))), - Effect.mapError((cause) => errorFor("register", cause)), + ), + ), + updateCreation: (id, values) => + Effect.gen(function* () { + const entry = yield* orchestrator.get(id); + const creation = yield* mergeInputs(yield* Ref.get(entry.creation), values); + yield* persistCreation(entry, creation); + return { id, creation }; + }), + replaceCreation: (id, creation) => + Effect.gen(function* () { + const entry = yield* orchestrator.get(id); + if (creation.service !== (yield* Ref.get(entry.creation)).service) + return yield* new ServiceError({ + operation: "compose", + message: `Reused service ${id} cannot change service kind`, + }); + yield* persistCreation(entry, creation); + return { id, creation }; + }), + configure, + }, + creations, + compositionOptions, ); - return { id, creation }; - }); + }, + Effect.catch((cause) => clearUnusedCredentials.pipe(Effect.andThen(Effect.fail(cause)))), + ); - const configureComposition = Effect.fn("Owner.configureComposition")(function* ( - input: CompositionConfig, - ) { - yield* Ref.get(draining).pipe( - Effect.flatMap((isDraining) => { - if (isDraining) return Effect.fail(errorFor("configure", "Owner is draining")); - return Schema.decodeEffect(Orchestrator.CompositionConfig)(input).pipe( - Effect.mapError((cause) => errorFor("configure", cause)), - ); - }), - Effect.tap((value) => - orchestrator - .configure(value) - .pipe(Effect.mapError((cause) => errorFor("configure", cause))), - ), - Effect.tap((value) => Ref.set(composition, value)), - Effect.tap((value) => persistComposition(value)), - Effect.asVoid, - ); - }); + const restart = Effect.fn("Owner.restart")(function* ( + id: string, + input: ServiceCreationInput | undefined, + ) { + if (input === undefined) return yield* orchestrator.restart(id); + const previous = yield* Ref.get((yield* orchestrator.get(id)).creation); + const next = yield* resolveCredentials(input); + if (next.service !== previous.service) + return yield* new ServiceError({ + operation: "restart", + message: "Service kind cannot change", + }); + return yield* orchestrator.restart(id, next); + }); - const updateCreation = Effect.fn("Owner.updateCreation")(function* ( - id: string, - inputs: Record, - ) { - return yield* getCreation(id).pipe( - Effect.flatMap((creation) => mergeInputs(creation, inputs)), - Effect.mapError((cause) => errorFor("supabase", cause)), - Effect.tap((creation) => persistCreation(id, creation)), - Effect.map((creation) => ({ id, creation })), + const observe = (entry: Entry, value: ServiceObservation) => + Effect.all({ config: Ref.get(entry.creation), endpoints: entry.namespace.bindings }).pipe( + Effect.map((current) => ({ ...value, ...current })), + ); + const observation = (id: string) => + orchestrator + .get(id) + .pipe( + Effect.flatMap((entry) => + entry.core.get.pipe(Effect.flatMap((value) => observe(entry, value))), + ), ); - }); + const observeAll = (values: ReadonlyArray<{ readonly id: string }>) => + Effect.forEach(values, ({ id }) => observation(id)); - const replaceCreation = Effect.fn("Owner.replaceCreation")(function* ( - id: string, - input: ServiceCreation, - ) { - const creation = yield* Schema.decodeEffect(ServiceCreation)(input).pipe( - Effect.mapError((cause) => errorFor("supabase", cause)), - ); - const current = yield* getCreation(id).pipe( - Effect.mapError((cause) => errorFor("supabase", cause)), - ); - if (creation.service !== current.service) - return yield* errorFor("supabase", `Reused service ${id} cannot change service kind`); - yield* persistCreation(id, creation); - return { id, creation }; - }); + const databaseData = Effect.fn("Owner.databaseData")(function* ( + id: string, + select: ( + recipe: CatalogRecipe, + ) => + | ((context: ServiceInstanceContext) => Effect.Effect) + | undefined, + ) { + const entry = yield* orchestrator.get(id); + const action = select(entry.recipe); + if (action === undefined) + return yield* new ServiceError({ + operation: "storage", + message: "Snapshots and data reset are only supported for databases", + }); + return yield* entry.core.storage( + Effect.acquireUseRelease( + Scope.make("parallel"), + (scope) => + Ref.get(entry.creation).pipe( + Effect.flatMap((config) => action({ id, config, scope })), + Effect.mapError(serviceError("storage")), + ), + (scope, exit) => Scope.close(scope, exit), + ), + ); + }); - const supabaseComposition = Effect.fn("Owner.supabaseComposition")( - ( - inputs: ReadonlyArray, - compositionOptions?: SupabaseCompositionOptions, - ) => + const handlers: Handlers = { + createService: (input) => + definitionChange( Effect.gen(function* () { - const overrides: Record = {}; - for (const input of inputs) { - for (const [key, value] of Object.entries(credentialOverridesFor(input))) { - if (overrides[key] !== undefined && overrides[key] !== value) - return yield* errorFor( - "credentials", - `Credential override ${key} conflicts within the stack composition`, - ); - overrides[key] = value; - } - } - yield* resolveStackCredentials(overrides, compositionOptions?.identity); - return yield* Effect.forEach(inputs, resolveCredentials); - }).pipe( - Effect.flatMap((creations) => - makeSupabaseComposition( - { - currentComposition: Ref.get(composition), - get: (id) => get(id).pipe(Effect.mapError(supabaseError)), - status: (id) => - observation(id).pipe( - Effect.map(({ lifecycle, wakeEnabled }) => ({ - lifecycle, - wakeEnabled, - })), - Effect.mapError(supabaseError), - ), - create: (creation) => createService(creation).pipe(Effect.mapError(supabaseError)), - destroy: (id) => destroy(id).pipe(Effect.mapError(supabaseError)), - bind: (id) => - orchestrator.get(id).pipe( - Effect.flatMap((registered) => registered.bind), - Effect.mapError(supabaseError), - ), - address: (id, endpoint, from) => - Ref.get(namespaces).pipe( - Effect.flatMap((values) => { - const namespace = values.get(id); - return namespace === undefined - ? Effect.fail(supabaseError("Service namespace is missing")) - : namespace.address(endpoint, from).pipe( - Effect.map(({ host, port }) => publicUrl(host, port)), - Effect.mapError(supabaseError), - ); - }), - ), - output: (id, name) => - orchestrator.get(id).pipe( - Effect.flatMap((registered) => { - const output = registered.outputs[name]; - return output === undefined - ? Effect.fail(supabaseError(`Missing output ${name}`)) - : output.pipe(Effect.mapError(supabaseError)); - }), - Effect.mapError(supabaseError), - ), - updateCreation: (id, values) => - updateCreation(id, values).pipe(Effect.mapError(supabaseError)), - replaceCreation: (id, creation) => - replaceCreation(id, creation).pipe(Effect.mapError(supabaseError)), - configure: (configuration) => - configureComposition(configuration).pipe(Effect.mapError(supabaseError)), - }, - creations, - compositionOptions, + const introduced = (yield* readSaved).credentials === undefined; + // Credentials a failed creation introduced must not pin a retry to its values. + return yield* resolveCredentials(input).pipe( + Effect.flatMap(addInstance), + Effect.catch((cause) => + (introduced ? clearUnusedCredentials : Effect.void).pipe( + Effect.andThen(Effect.fail(cause)), + ), + ), + ); + }), + ).pipe(rpcError("createService")), + startService: ({ id }) => orchestrator.start(id).pipe(rpcError("startService")), + readyService: ({ id }) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => entry.core.ready), + rpcError("readyService"), + ), + stopService: ({ id }) => orchestrator.stop(id).pipe(rpcError("stopService")), + // A config-changing restart can adopt saved credentials, so it serializes with definition + // changes that introduce or roll them back. + restartService: ({ id, config }) => + (config === undefined ? restart(id, config) : definitionChange(restart(id, config))).pipe( + rpcError("restartService"), + ), + destroyService: ({ id }) => + definitionChange(orchestrator.destroy(id)).pipe(rpcError("destroyService")), + prepareService: ({ id }) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => + Ref.get(entry.creation).pipe( + Effect.flatMap( + (creation) => entry.recipe.definition.prepare?.(creation) ?? Effect.void, ), ), - Effect.catch((cause) => - clearCredentialsIfUnused().pipe(Effect.andThen(Effect.fail(cause))), - ), - Effect.mapError((cause) => { - if (cause instanceof OwnerError) return cause; - if (cause instanceof SupabaseCompositionError && cause.cause instanceof OwnerError) - return cause.cause; - return errorFor("supabase", cause); - }), ), - ); - - const prepare = Effect.fn("Owner.prepare")(function* (id: string) { - return yield* getRecipe(id).pipe( - Effect.flatMap((recipe) => recipe.definition.prepare?.(recipe.creation) ?? Effect.void), - Effect.mapError((cause) => errorFor("prepare", cause)), - ); - }); - const start = Effect.fn("Owner.start")((id: string) => - operation("start", orchestrator.start(id)), - ); - const ready = Effect.fn("Owner.ready")((id: string) => - operation("ready", orchestrator.get(id).pipe(Effect.flatMap((value) => value.core.ready))), - ); - const stop = Effect.fn("Owner.stop")((id: string) => operation("stop", orchestrator.stop(id))); - const restart = Effect.fn("Owner.restart")(function* (id: string, input?: unknown) { - const effect = - input === undefined - ? orchestrator.restart(id) - : Effect.gen(function* () { - const previous = yield* getCreation(id); - const next = yield* resolveCredentials(input); - if (next.service !== previous.service) - return yield* errorFor("restart", "Service kind cannot change"); - return yield* orchestrator.restart(id, next); - }); - return yield* operation("restart", effect); - }); - const destroy = Effect.fn("Owner.destroy")((id: string) => - operation("destroy", orchestrator.destroy(id)), - ); - const credentials = Effect.fn("Owner.credentials")((id: string, from: "host" | "runtime") => - get(id).pipe( - Effect.flatMap(({ creation }) => - Ref.get(namespaces).pipe( - Effect.flatMap((values) => { - const namespace = values.get(id); - if (namespace === undefined) - return Effect.fail(errorFor("credentials", "Service namespace is missing")); - const address = (endpoint: string, source: "host" | "runtime") => - namespace.address(endpoint, source).pipe( - Effect.mapError( - (cause) => - new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - const password = () => - getCreation(id).pipe( - Effect.flatMap((value) => - value.service === "database" - ? Effect.succeed(Redacted.value(value.config.databasePassword)) - : Effect.fail( - new EndpointError({ - message: "Credentials require a database", - }), - ), - ), - Effect.mapError((cause) => - cause instanceof EndpointError - ? cause - : new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - return credentialsFor(creation, address, password, from).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - ); - }), + rpcError("prepareService"), + ), + status: ({ id }) => observation(id).pipe(rpcError("status")), + followStatus: ({ id }) => + Stream.unwrap( + orchestrator + .get(id) + .pipe( + Effect.map((entry) => + entry.core.observation.pipe(Stream.mapEffect((value) => observe(entry, value))), + ), ), - ), + ).pipe(Stream.mapError((cause) => stackError("followStatus", cause))), + logs: ({ id }) => + Stream.unwrap(orchestrator.get(id).pipe(Effect.map((entry) => entry.recipe.logs))).pipe( + Stream.map(({ stream, bytes }) => ({ stream, bytes })), + Stream.mapError((cause) => stackError("logs", cause)), ), - ); - const compose = Effect.fn("Owner.compose")( - (creations: ReadonlyArray, options?: SupabaseCompositionOptions) => - compositionGate.withPermits(1)(supabaseComposition(creations, options)), - ); - const configure = Effect.fn("Owner.configure")((input: CompositionConfig) => - compositionGate.withPermits(1)(configureComposition(input)), - ); - const compositionStart = orchestrator.startComposition.pipe( - Effect.mapError((cause) => errorFor("start", cause)), - Effect.flatMap((values) => Effect.forEach(values, (value) => observation(value.id))), - Effect.withSpan("Owner.startComposition"), - ); - const compositionStop = orchestrator.stopComposition.pipe( - Effect.mapError((cause) => errorFor("stop", cause)), - Effect.flatMap((values) => Effect.forEach(values, (value) => observation(value.id))), - Effect.withSpan("Owner.stopComposition"), - ); - const compositionRestart = orchestrator.restartComposition.pipe( - Effect.mapError((cause) => errorFor("restart", cause)), - Effect.flatMap((values) => Effect.forEach(values, (value) => observation(value.id))), - Effect.withSpan("Owner.restartComposition"), - ); - const saveSnapshot = Effect.fn("Owner.saveSnapshot")((id: string, key: string) => - get(id).pipe( - Effect.flatMap(({ creation }) => - creation.service === "database" - ? storage( - id, - Effect.gen(function* () { - const current = yield* getRecipe(id); - const save = current.saveDatabaseSnapshot; - if (save === undefined) - return yield* errorFor("saveSnapshot", "Database snapshots are unavailable"); - return yield* Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - save({ id, config: current.creation, scope }, key).pipe( - Effect.mapError((cause) => errorFor("saveSnapshot", cause)), - ), - (scope, exit) => Scope.close(scope, exit), - ); - }), - ) - : Effect.fail(errorFor("saveSnapshot", "Snapshots are only supported for databases")), + credentials: ({ id, from }) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => + Ref.get(entry.creation).pipe( + Effect.flatMap((creation) => credentialsFor(creation, entry.namespace.address, from)), + ), ), + rpcError("credentials"), ), - ); - const restoreSnapshot = Effect.fn("Owner.restoreSnapshot")((id: string, key: string) => - get(id).pipe( - Effect.flatMap(({ creation }) => - creation.service === "database" - ? storage( - id, - Effect.gen(function* () { - const current = yield* getRecipe(id); - const restore = current.restoreDatabaseSnapshot; - if (restore === undefined) - return yield* errorFor("restoreSnapshot", "Database snapshots are unavailable"); - return yield* Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - restore({ id, config: current.creation, scope }, key).pipe( - Effect.mapError((cause) => errorFor("restoreSnapshot", cause)), - ), - (scope, exit) => Scope.close(scope, exit), - ); - }), - ) - : Effect.fail( - errorFor("restoreSnapshot", "Snapshots are only supported for databases"), - ), - ), + saveSnapshot: ({ id, key }) => + databaseData(id, ({ saveDatabaseSnapshot: save }) => + save === undefined ? undefined : (context) => save(context, key), + ).pipe(rpcError("saveSnapshot")), + restoreSnapshot: ({ id, key }) => + databaseData(id, ({ restoreDatabaseSnapshot: restore }) => + restore === undefined ? undefined : (context) => restore(context, key), + ).pipe(rpcError("restoreSnapshot")), + resetData: ({ id }) => + databaseData(id, ({ resetDatabaseData }) => resetDatabaseData).pipe(rpcError("resetData")), + supabaseComposition: ({ services, reuseIds, identity }) => + definitionChange(compose(services, { reuseIds, identity })).pipe( + rpcError("supabaseComposition"), ), - ); - const resetData = Effect.fn("Owner.resetData")((id: string) => - get(id).pipe( - Effect.flatMap(({ creation }) => - creation.service === "database" - ? storage( - id, - Effect.gen(function* () { - const current = yield* getRecipe(id); - const reset = current.resetDatabaseData; - if (reset === undefined) - return yield* errorFor("resetData", "Database reset is unavailable"); - return yield* Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - reset({ id, config: current.creation, scope }).pipe( - Effect.mapError((cause) => errorFor("resetData", cause)), - ), - (scope, exit) => Scope.close(scope, exit), - ); - }), - ) - : Effect.fail(errorFor("resetData", "Reset is only supported for databases")), - ), + configureComposition: (configuration) => + definitionChange(configure(configuration)).pipe(rpcError("configureComposition")), + startComposition: () => + orchestrator.startComposition.pipe(Effect.flatMap(observeAll), rpcError("startComposition")), + stopComposition: () => + orchestrator.stopComposition.pipe(Effect.flatMap(observeAll), rpcError("stopComposition")), + restartComposition: () => + orchestrator.restartComposition.pipe( + Effect.flatMap(observeAll), + rpcError("restartComposition"), ), - ); - const stopNamespace = operation("stopNamespace", orchestrator.stopNamespace).pipe( - Effect.withSpan("Owner.stopNamespace"), - ); - const destroyNamespace = operation( - "destroyNamespace", - orchestrator.destroyNamespace.pipe( + }; + + const sweep = sweepContainers(options.saved, options.root).pipe(Effect.provideContext(services)); + const getStackCredentials = readSaved.pipe( + Effect.flatMap(({ credentials }) => + credentials === undefined + ? Effect.fail( + new CredentialError({ message: "Stack credentials have not been established" }), + ) + : Effect.succeed(credentials), + ), + Effect.withSpan("Owner.getStackCredentials"), + ); + + return { + handlers, + getStackCredentials, + namespace: { + stop: orchestrator.stopNamespace.pipe( + Effect.andThen(sweep), + definitionGate.withPermits(1), + Effect.withSpan("Owner.stopNamespace"), + ), + destroy: orchestrator.destroyNamespace.pipe( Effect.andThen(network.release), - Effect.andThen( - options.saved.runtime === "native" - ? Effect.void - : Container.removeStackContainers({ - engine: options.saved.runtime, - stackId: options.saved.id, - root: options.root, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ), - Effect.andThen(options.state.remove(options.saved.id)), + Effect.andThen(sweep), + Effect.andThen(options.state.remove(stackId)), + definitionGate.withPermits(1), + Effect.withSpan("Owner.destroyNamespace"), ), - ).pipe(Effect.withSpan("Owner.destroyNamespace")); - const setDraining = Effect.fn("Owner.setDraining")((value: boolean) => - Ref.set(draining, value).pipe(Effect.mapError((cause) => errorFor("draining", cause))), - ); - const getServing = Effect.gen(function* () { - return !(yield* Ref.get(draining)); - }).pipe(Effect.withSpan("Owner.getServing")); - - const owner: Interface = { - services: { - create: createService, - get, - list: Ref.get(recipes).pipe( - Effect.map((values) => - [...values].map(([id, recipe]) => ({ - id, - creation: recipe.creation, - })), - ), - Effect.mapError((cause) => errorFor("list", cause)), - ), - }, - core: { - get: observation, - status: observation, - followStatus: (id) => - Stream.unwrap( - orchestrator.get(id).pipe( - Effect.map((instance) => instance.core.observation), - Effect.mapError((cause) => errorFor("status", cause)), - ), - ).pipe(Stream.mapEffect((value) => enrichObservation(id, value))), - prepare, - logs: (id) => - Stream.unwrap( - getRecipe(id).pipe( - Effect.map((recipe) => recipe.logs), - Effect.mapError((cause) => errorFor("logs", cause)), - ), - ).pipe(Stream.mapError((cause) => errorFor("logs", cause))), - start, - ready, - stop, - restart, - destroy, - }, - composition: { - supabase: compose, - configure, - get: Ref.get(composition), - start: compositionStart, - stop: compositionStop, - restart: compositionRestart, - }, - credentials, - getStackCredentials, - snapshots: { - saveSnapshot, - restoreSnapshot, - resetData, - }, - namespace: { - stop: stopNamespace, - destroy: destroyNamespace, - }, - setDraining, - getServing, - }; - return owner; - }); + }, + setDraining: (value) => Ref.set(draining, value), + getServing: Ref.get(draining).pipe(Effect.map((isDraining) => !isDraining)), + } satisfies Interface; +}); export const layer = (options: Omit) => Layer.effect( Service, Effect.gen(function* () { const state = yield* State.Service; - const orchestrator = yield* Orchestrator.Service; - const network = yield* Network.Service; - return Service.of( - yield* makeOwnerWithDependencies({ ...options, state }, orchestrator, network), - ); + return Service.of(yield* makeOwner({ ...options, state })); }), ).pipe( - Layer.provide(Layer.fresh(Orchestrator.layer)), Layer.provide( Network.layer({ stackId: options.saved.id, diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 16352cf3e1..83352580b0 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -1,9 +1,10 @@ -import { Data, Schema } from "effect"; +import { Exit, Schema } from "effect"; import { Rpc, RpcGroup } from "effect/unstable/rpc"; import { ServiceCreation, ServiceCreationInput } from "./services/Catalog.ts"; -import { CompositionConfig } from "./Orchestrator.ts"; +import { causeMessage, CompositionConfig, OrchestratorError } from "./Orchestrator.ts"; import { CommandInvocation } from "./Commands.ts"; import { StackIdentityInput } from "./State.ts"; +import { failureMessage } from "./internal/failure-message.ts"; const Outcome = Schema.Struct({ id: Schema.String, @@ -11,15 +12,28 @@ const Outcome = Schema.Struct({ error: Schema.optionalKey(Schema.String), }); -export const StackErrorSchema = Schema.TaggedStruct("StackError", { +/** A typed failure returned by the stack owner. */ +export class StackError extends Schema.TaggedError()("StackError", { operation: Schema.String, message: Schema.String, outcomes: Schema.optionalKey(Schema.Array(Outcome)), -}); -type StackErrorPayload = Omit, "_tag">; +}) {} -/** A typed failure returned by the stack owner. */ -export class StackError extends Data.TaggedError("StackError") {} +/** Maps an owner failure to the RPC error, preserving per-member composition outcomes. */ +export const stackError = (operation: string, cause: unknown): StackError => { + if (Schema.is(StackError)(cause)) return cause; + if (cause instanceof OrchestratorError && cause.outcomes !== undefined) + return new StackError({ + operation, + message: failureMessage(cause), + outcomes: cause.outcomes.map(({ id, result }) => ({ + id, + succeeded: Exit.isSuccess(result), + ...(Exit.isFailure(result) ? { error: causeMessage(result.cause) } : {}), + })), + }); + return new StackError({ operation, message: failureMessage(cause) }); +}; const ServiceErrorSchema = Schema.TaggedStruct("ServiceError", { operation: Schema.String, @@ -73,47 +87,45 @@ export const RunCommandPayload = Schema.Struct({ }).annotate({ parseOptions: { onExcessProperty: "error" } }); export type RunCommandPayload = Schema.Schema.Type; -/** The private transport contract; lifecycle admission remains in the owner. */ -export const StackRpc = RpcGroup.make( +/** Instance and composition operations served by the owner. */ +export const OwnerRpc = RpcGroup.make( Rpc.make("createService", { payload: ServiceCreationInput, success: Definition, - error: StackErrorSchema, + error: StackError, }), - Rpc.make("getService", { payload: Instance, success: Definition, error: StackErrorSchema }), - Rpc.make("listServices", { success: Schema.Array(Definition), error: StackErrorSchema }), - Rpc.make("startService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("readyService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("stopService", { payload: Instance, error: StackErrorSchema }), + Rpc.make("startService", { payload: Instance, error: StackError }), + Rpc.make("readyService", { payload: Instance, error: StackError }), + Rpc.make("stopService", { payload: Instance, error: StackError }), Rpc.make("restartService", { payload: { ...Instance, config: Schema.optionalKey(ServiceCreationInput) }, - error: StackErrorSchema, + error: StackError, }), - Rpc.make("destroyService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("prepareService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("status", { payload: Instance, success: Observation, error: StackErrorSchema }), + Rpc.make("destroyService", { payload: Instance, error: StackError }), + Rpc.make("prepareService", { payload: Instance, error: StackError }), + Rpc.make("status", { payload: Instance, success: Observation, error: StackError }), Rpc.make("followStatus", { payload: Instance, success: Observation, - error: StackErrorSchema, + error: StackError, stream: true, }), - Rpc.make("logs", { payload: Instance, success: Log, error: StackErrorSchema, stream: true }), + Rpc.make("logs", { payload: Instance, success: Log, error: StackError, stream: true }), Rpc.make("credentials", { payload: { ...Instance, from: Schema.Literals(["host", "runtime"]) }, success: Schema.Record(Schema.String, Schema.String), - error: StackErrorSchema, + error: StackError, }), Rpc.make("saveSnapshot", { payload: { ...Instance, key: Schema.String }, - error: StackErrorSchema, + error: StackError, }), Rpc.make("restoreSnapshot", { payload: { ...Instance, key: Schema.String }, success: Schema.Boolean, - error: StackErrorSchema, + error: StackError, }), - Rpc.make("resetData", { payload: Instance, error: StackErrorSchema }), + Rpc.make("resetData", { payload: Instance, error: StackError }), Rpc.make("supabaseComposition", { payload: { services: Schema.Array(ServiceCreationInput), @@ -121,22 +133,25 @@ export const StackRpc = RpcGroup.make( identity: Schema.optionalKey(StackIdentityInput), }, success: Schema.Array(Definition), - error: StackErrorSchema, + error: StackError, }), - Rpc.make("configureComposition", { payload: CompositionConfig, error: StackErrorSchema }), - Rpc.make("getComposition", { success: CompositionConfig, error: StackErrorSchema }), - Rpc.make("startComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), - Rpc.make("stopComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), - Rpc.make("restartComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), - Rpc.make("shutdown", { payload: { destroy: Schema.Boolean }, error: StackErrorSchema }), + Rpc.make("configureComposition", { payload: CompositionConfig, error: StackError }), + Rpc.make("startComposition", { success: Schema.Array(Observation), error: StackError }), + Rpc.make("stopComposition", { success: Schema.Array(Observation), error: StackError }), + Rpc.make("restartComposition", { success: Schema.Array(Observation), error: StackError }), +); + +/** The private transport contract; lifecycle admission remains in the owner. */ +export const StackRpc = OwnerRpc.add( + Rpc.make("shutdown", { payload: { destroy: Schema.Boolean }, error: StackError }), Rpc.make("runCommand", { payload: RunCommandPayload, success: CommandEvent, - error: StackErrorSchema, + error: StackError, stream: true, }), Rpc.make("commandInput", { payload: { attachmentId: Schema.String, bytes: Schema.NullOr(Schema.Uint8ArrayFromBase64) }, - error: StackErrorSchema, + error: StackError, }), ); diff --git a/packages/stack/src/Rpc.unit.test.ts b/packages/stack/src/Rpc.unit.test.ts new file mode 100644 index 0000000000..7cf057996b --- /dev/null +++ b/packages/stack/src/Rpc.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Schema } from "effect"; +import { OrchestratorError } from "./Orchestrator.ts"; +import { StackError, stackError } from "./Rpc.ts"; +import { ServiceError } from "./Service.ts"; + +const roundTrip = (error: StackError) => + Schema.encodeEffect(StackError)(error).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(StackError)), + ); + +describe("stackError", () => { + it.effect("describes a wrapped error without a message by its cause", () => + Effect.gen(function* () { + const cause = new Cause.UnknownError(new Error("container is gone")); + const failure = stackError( + "stop", + new ServiceError({ operation: "stop", message: cause.message, cause }), + ); + + expect((yield* roundTrip(failure)).message).toBe("container is gone"); + }), + ); + + it.effect("names an error whose causes carry no message", () => + Effect.gen(function* () { + const failure = stackError("stop", new Cause.UnknownError(undefined)); + + expect((yield* roundTrip(failure)).message).toBe("UnknownError"); + }), + ); + + it.effect("keeps composition outcomes when the orchestrator error lacks a message", () => + Effect.gen(function* () { + const cause = new Cause.UnknownError(42); + const failure = stackError( + "startComposition", + new OrchestratorError({ operation: "start", message: cause.message, cause, outcomes: [] }), + ); + + const decoded = yield* roundTrip(failure); + expect(decoded.message).toBe("42"); + expect(decoded.outcomes).toEqual([]); + }), + ); +}); diff --git a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts index 7950c4dc0e..99f9b6c9d3 100644 --- a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts +++ b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts @@ -81,11 +81,6 @@ it.live("retains saved state when destroy sweep fails and retries after engine r const baseOwner = yield* Layer.build(ownerLayer).pipe( Effect.map((context) => Context.get(context, Owner.Service)), ); - const ownership = { - engine: "docker" as const, - stackId: saved.id, - root: `${root}/data`, - }; const owner = baseOwner; const acquired = yield* acquireHost(state, saved.id); const runnerLayer = yield* Layer.build( @@ -106,13 +101,11 @@ it.live("retains saved state when destroy sweep fails and retries after engine r }, acquired.server, acquired.closeConnections, - ownership, ).pipe( Effect.provideService( CommandRunner.Service, Context.get(runnerLayer, CommandRunner.Service), ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, engine), ); yield* runtime.serve; const client = yield* clientFor(runtime.endpoint.port); diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 90ec983139..59fbc2212f 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -17,14 +17,12 @@ import { } from "effect"; import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/unstable/rpc"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import { ChildProcessSpawner } from "effect/unstable/process"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration observes exact listener closure. import * as Net from "node:net"; import { acquireHost, launchHost } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; -import { OwnerError } from "./Owner.ts"; import { OrchestratorError } from "./Orchestrator.ts"; -import { CommandEvent, StackErrorSchema, StackRpc } from "./Rpc.ts"; +import { CommandEvent, StackError, StackRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import { makeRuntime } from "./StackHost.ts"; import { postgres } from "./Commands.ts"; @@ -71,7 +69,7 @@ const permissiveCommandClientFor = (port: number) => Rpc.make("runCommand", { payload: Schema.Unknown, success: CommandEvent, - error: StackErrorSchema, + error: StackError, stream: true, }), ), @@ -131,14 +129,6 @@ const inProcessRuntime = ( owner: Parameters[0], state: State.Interface, root: string, - options?: { - readonly container?: { - readonly engine: "docker" | "podman"; - readonly stackId: string; - readonly root: string; - }; - readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; - }, ) => Effect.gen(function* () { const acquired = yield* acquireHost(state, "stack"); @@ -150,7 +140,7 @@ const inProcessRuntime = ( runtime: "native", }), ); - const runtimeEffect = makeRuntime( + const runtime = yield* makeRuntime( owner, { stackId: "stack", @@ -160,15 +150,9 @@ const inProcessRuntime = ( }, acquired.server, acquired.closeConnections, - options?.container, ).pipe( Effect.provideService(CommandRunner.Service, Context.get(toolContext, CommandRunner.Service)), ); - const runtime = yield* options?.spawner === undefined - ? runtimeEffect - : runtimeEffect.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), - ); yield* runtime.serve; return { runtime, port: acquired.port }; }); @@ -200,6 +184,22 @@ const abortBeforeRpcBody = (port: number) => }); }); +const occupiedPort = Effect.acquireRelease( + Effect.callback((resume) => { + const server = Net.createServer(); + server.once("error", (cause) => resume(Effect.fail(hostTestError(cause)))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => Effect.callback((resume) => void server.close(() => resume(Effect.void))), +).pipe( + Effect.flatMap((server) => { + const address = server.address(); + return typeof address === "object" && address !== null + ? Effect.succeed(address.port) + : Effect.fail(new HostTestError({ message: "Occupied listener has no port" })); + }), +); + it.live("preserves composition outcomes over RPC", () => Effect.scoped( Effect.gen(function* () { @@ -221,41 +221,36 @@ it.live("preserves composition outcomes over RPC", () => root: `${root}/data`, cacheRoot: "/tmp/supabase-stack-artifacts", }); - const failed = new OrchestratorError({ - operation: "start", - message: "member failed", - cause: new Error("member failed", { cause: new Error("EACCES: permission denied") }), - }); - const delayedOwner = { - ...owner, - composition: { - ...owner.composition, - start: Effect.fail( - new OwnerError({ - operation: "composition", - message: "Composition start had failures", - cause: new OrchestratorError({ - operation: "start", - message: "Composition start had failures", - outcomes: [ - { id: "healthy", result: Exit.succeed(undefined) }, - { id: "failed", result: Exit.fail(failed) }, - ], - }), - }), - ), - }, - }; - const { runtime } = yield* inProcessRuntime(delayedOwner, state, root); + const { runtime } = yield* inProcessRuntime(owner, state, root); const client = yield* clientFor(runtime.endpoint.port); + const port = yield* occupiedPort; + const lazy = yield* client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + const blocked = yield* client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port } }, + }); + yield* client.configureComposition({ + members: [ + { id: lazy.id, activation: "lazy" }, + { id: blocked.id, activation: "eager" }, + ], + dependencies: [], + }); + const error = yield* client.startComposition().pipe(Effect.flip); + expect("outcomes" in error).toBe(true); if (!("outcomes" in error)) return yield* Effect.die("Missing composition outcomes"); expect(error.outcomes).toEqual([ - { id: "healthy", succeeded: true }, - { id: "failed", succeeded: false, error: "member failed: EACCES: permission denied" }, + { id: lazy.id, succeeded: true }, + { id: blocked.id, succeeded: false, error: expect.stringContaining(String(port)) }, ]); - yield* client.shutdown({ destroy: false }); + yield* client.shutdown({ destroy: true }); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -285,14 +280,16 @@ it.live("keeps serving when namespace shutdown fails", () => ...owner, namespace: { ...owner.namespace, - stop: Effect.fail(new OwnerError({ operation: "stop", message: "cleanup failed" })), + stop: Effect.fail( + new OrchestratorError({ operation: "stop", message: "cleanup failed" }), + ), }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); const client = yield* clientFor(runtime.endpoint.port); const failure = yield* client.shutdown({ destroy: false }).pipe(Effect.flip); expect("operation" in failure).toBe(true); - expect((yield* client.listServices()).length).toBe(0); + yield* client.configureComposition({ members: [], dependencies: [] }); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -324,19 +321,15 @@ it.live("reports destroy and fallback stop failures together", () => id: string, reason: string, ) => - new OwnerError({ + new OrchestratorError({ operation, message, - cause: new OrchestratorError({ - operation, - message, - outcomes: [ - { - id, - result: Exit.fail(new OrchestratorError({ operation, message: reason })), - }, - ], - }), + outcomes: [ + { + id, + result: Exit.fail(new OrchestratorError({ operation, message: reason })), + }, + ], }); const failedOwner = { ...owner, @@ -478,7 +471,7 @@ it.live("retains ownership when namespace shutdown defects and retries cleanup", 200, ); expect(yield* owner.getServing).toBe(true); - expect((yield* client.listServices()).length).toBe(0); + yield* client.configureComposition({ members: [], dependencies: [] }); yield* runtime.shutdown(false); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), @@ -546,14 +539,14 @@ it.live( ), ); yield* abortBeforeRpcBody(endpoint.port); - expect((yield* client.listServices()).length).toBe(0); + expect(yield* client.startComposition()).toEqual([]); const mail = yield* client.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" }, smtp: { port: "auto" }, pop3: { port: "auto" } }, }); expect(mail.creation.service).toBe("mail"); - expect((yield* client.listServices()).length).toBe(1); + expect((yield* client.status({ id: mail.id })).lifecycle).toBe("stopped"); const toolAttachment = "early-stdin"; const commandEvents = yield* client .runCommand({ @@ -747,12 +740,12 @@ it.live("withdraws a command waiting for its prerequisite", () => const allow = yield* Deferred.make(); const delayedOwner = { ...owner, - core: { - ...owner.core, - start: (id: string) => + handlers: { + ...owner.handlers, + startService: (payload: { readonly id: string }) => Deferred.succeed(entered, undefined).pipe( Effect.andThen(Deferred.await(allow)), - Effect.andThen(owner.core.start(id)), + Effect.andThen(owner.handlers.startService(payload)), Effect.ensuring(Deferred.succeed(cancelled, undefined)), ), }, @@ -779,3 +772,252 @@ it.live("withdraws a command waiting for its prerequisite", () => }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +const disconnectFixture = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix }); + const state = yield* stateFor(`${root}/state`); + const saved: State.SavedStack = { + id: "stack", + runtime: "native", + identity: { projectRoot: root, branchContext: "main", stackName: prefix }, + instances: [], + composition: { members: [], dependencies: [] }, + ports: [], + }; + yield* state.save(saved); + return { root, state, saved }; + }); + +it.live("finishes a service creation after its caller disconnects", () => + Effect.scoped( + Effect.gen(function* () { + const { root, state, saved } = yield* disconnectFixture("stack-host-create-abort-"); + const persisted = yield* Deferred.make(); + const allow = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); + const owner = yield* ownerFor({ + saved, + state: { + ...state, + save: (next) => + state + .save(next) + .pipe( + Effect.andThen( + next.instances.length === 0 + ? Effect.void + : Deferred.succeed(persisted, undefined).pipe( + Effect.andThen(Deferred.await(allow)), + ), + ), + ), + }, + root: `${root}/data`, + cacheRoot: "/tmp/supabase-stack-artifacts", + }); + const interrupted = yield* Deferred.make(); + const { runtime } = yield* inProcessRuntime( + { + ...owner, + handlers: { + ...owner.handlers, + createService: (input: Parameters[0]) => + owner.handlers + .createService(input) + .pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + }, + }, + state, + root, + ); + const client = yield* clientFor(runtime.endpoint.port); + const creation = yield* Effect.forkScoped( + client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }), + ); + yield* Deferred.await(persisted); + yield* Fiber.interrupt(creation); + yield* Deferred.await(interrupted); + yield* Deferred.succeed(allow, undefined); + // Definition changes are serialized, so this returns after the abandoned creation settles. + yield* client.configureComposition({ members: [], dependencies: [] }); + + const [instance, ...others] = (yield* state.read(saved.id))?.instances ?? []; + if (instance === undefined) return yield* Effect.die("creation was not persisted"); + expect(others).toEqual([]); + expect((yield* client.status({ id: instance.id })).lifecycle).toBe("stopped"); + yield* client.destroyService({ id: instance.id }); + yield* client.shutdown({ destroy: true }); + yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); + expect(yield* state.read(saved.id)).toBeUndefined(); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("persists a composition change after its caller disconnects", () => + Effect.scoped( + Effect.gen(function* () { + const { root, state, saved } = yield* disconnectFixture("stack-host-configure-abort-"); + const entered = yield* Deferred.make(); + const allow = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); + const owner = yield* ownerFor({ + saved, + state: { + ...state, + save: (next) => + (next.composition.members.length === 0 + ? Effect.void + : Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(allow))) + ).pipe(Effect.andThen(state.save(next))), + }, + root: `${root}/data`, + cacheRoot: "/tmp/supabase-stack-artifacts", + }); + const interrupted = yield* Deferred.make(); + const { runtime } = yield* inProcessRuntime( + { + ...owner, + handlers: { + ...owner.handlers, + configureComposition: ( + input: Parameters[0], + ) => + owner.handlers + .configureComposition(input) + .pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + }, + }, + state, + root, + ); + const client = yield* clientFor(runtime.endpoint.port); + const mail = yield* client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + const members = [{ id: mail.id, activation: "eager" as const }]; + const configure = yield* Effect.forkScoped( + client.configureComposition({ members, dependencies: [] }), + ); + yield* Deferred.await(entered); + yield* Fiber.interrupt(configure); + yield* Deferred.await(interrupted); + yield* Deferred.succeed(allow, undefined); + yield* client.createService({ service: "mail", config: {}, endpoints: {} }); + + expect((yield* state.read(saved.id))?.composition).toEqual({ members, dependencies: [] }); + yield* client.shutdown({ destroy: true }); + yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +const abandonedComposition = (prefix: string, destroy: boolean) => + Effect.gen(function* () { + const { root, state, saved } = yield* disconnectFixture(prefix); + const persisted = yield* Deferred.make(); + const allow = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); + const owner = yield* ownerFor({ + saved, + state: { + ...state, + save: (next) => + state + .save(next) + .pipe( + Effect.andThen( + next.instances.length === 0 + ? Effect.void + : Deferred.succeed(persisted, undefined).pipe( + Effect.andThen(Deferred.await(allow)), + ), + ), + ), + }, + root: `${root}/data`, + cacheRoot: "/tmp/supabase-stack-artifacts", + }); + const interrupted = yield* Deferred.make(); + const draining = yield* Deferred.make(); + const { runtime } = yield* inProcessRuntime( + { + ...owner, + setDraining: (value: boolean) => + owner + .setDraining(value) + .pipe(Effect.andThen(value ? Deferred.succeed(draining, undefined) : Effect.void)), + handlers: { + ...owner.handlers, + supabaseComposition: (input: Parameters[0]) => + owner.handlers + .supabaseComposition(input) + .pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + }, + }, + state, + root, + ); + const client = yield* clientFor(runtime.endpoint.port); + const composition = yield* Effect.forkScoped( + client.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("abandoned-composition-password"), + jwtSecret: Redacted.make("abandoned-composition-jwt-secret-at-least-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + ], + }), + ); + yield* Deferred.await(persisted); + yield* Fiber.interrupt(composition); + yield* Deferred.await(interrupted); + const shutdown = yield* Effect.forkScoped(client.shutdown({ destroy })); + yield* Deferred.await(draining); + yield* Deferred.succeed(allow, undefined); + yield* Fiber.join(shutdown); + yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); + return { root, state, saved }; + }); + +it.live("stops a stack only after an abandoned composition settles", () => + Effect.scoped( + Effect.gen(function* () { + const { state, saved } = yield* abandonedComposition("stack-host-compose-stop-", false); + + const current = yield* state.read(saved.id); + const instanceIds = current?.instances.map(({ id }) => id) ?? []; + expect(instanceIds).toHaveLength(1); + expect(current?.composition.members.map(({ id }) => id)).toEqual(instanceIds); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("destroys a stack only after an abandoned composition settles", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { root, state, saved } = yield* abandonedComposition( + "stack-host-compose-destroy-", + true, + ); + + expect(yield* state.read(saved.id)).toBeUndefined(); + const data = `${root}/data`; + expect((yield* fs.exists(data)) ? yield* fs.readDirectory(data) : []).toEqual([]); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index e4fec07885..361f6513b4 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -14,7 +14,6 @@ import { Ref, Scope, Semaphore, - Stream, Path, } from "effect"; import * as HttpServer from "effect/unstable/http/HttpServer"; @@ -24,15 +23,10 @@ import * as RpcServer from "effect/unstable/rpc/RpcServer"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import { acquireHost, HostEndpoint } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; -import { OwnerError } from "./Owner.ts"; -import * as Orchestrator from "./Orchestrator.ts"; -import { StackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; +import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; import * as State from "./State.ts"; import { makeCommandAttachments } from "./host/CommandAttachments.ts"; import * as CommandRunner from "./host/CommandRunner.ts"; -import * as Container from "./runtime/Container.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type { CatalogLog } from "./services/Catalog.ts"; export interface StackHostOptions { readonly stateRoot: string; @@ -56,33 +50,6 @@ const hostError = (operation: string, cause: unknown, reason?: "runtime-unavaila ...(reason === undefined ? {} : { reason }), }); -const stackError = (operation: string, cause: unknown): StackError => { - if (cause instanceof StackError) return cause; - const orchestration = - cause instanceof Orchestrator.OrchestratorError - ? cause - : cause instanceof OwnerError && cause.cause instanceof Orchestrator.OrchestratorError - ? cause.cause - : undefined; - if (orchestration?.outcomes !== undefined) { - return new StackError({ - operation, - message: orchestration.message, - outcomes: orchestration.outcomes.map(({ id, result }) => ({ - id, - succeeded: Exit.isSuccess(result), - ...(Exit.isFailure(result) ? { error: Orchestrator.causeMessage(result.cause) } : {}), - })), - }); - } - return new StackError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - }); -}; - -const log = (value: CatalogLog) => ({ stream: value.stream, bytes: value.bytes }); - const isOpen = (value: boolean): Effect.Effect => value ? Effect.void @@ -132,25 +99,12 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( endpoint: HostEndpoint, server: HttpServer.HttpServer["Service"], closeConnections: Effect.Effect, - container?: { - readonly engine: "docker" | "podman"; - readonly stackId: string; - readonly root: string; - }, - ): Effect.Effect< - StackHostRuntime, - never, - Scope.Scope | CommandRunner.Service | ChildProcessSpawner.ChildProcessSpawner - > => + ): Effect.Effect => Effect.gen(function* () { const scope = yield* Scope.Scope; const runner = yield* CommandRunner.Service; - const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const attachments = yield* makeCommandAttachments({ - admit: owner.getServing.pipe( - Effect.flatMap(isOpen), - Effect.mapError((cause) => stackError("host", cause)), - ), + admit: owner.getServing.pipe(Effect.flatMap(isOpen)), run: (input) => "stdin" in input ? runner.run({ @@ -210,21 +164,10 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( if (response !== undefined && responseClosedSignal !== undefined) yield* watchResponse(response, responseClosedSignal); let retiringAfterDestroyFailure = false; - const removeOwned = - container === undefined - ? Effect.void - : Container.removeStackContainers(container).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childSpawner, - ), - Effect.mapError((cause) => stackError("shutdown", cause)), - ); const stopOwned = Effect.gen(function* () { yield* attachments.stopAll; yield* runner.cleanup; yield* owner.namespace.stop; - yield* removeOwned; }); const finish = Effect.gen(function* () { if (response !== undefined) { @@ -299,11 +242,12 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( Effect.catchCause((cause) => retiringAfterDestroyFailure ? Effect.failCause(cause) - : owner.setDraining(false).pipe( - Effect.mapError((reset) => stackError("shutdown", reset)), - Effect.andThen(gate.withPermits(1)(Ref.set(current, undefined))), - Effect.andThen(Effect.failCause(cause)), - ), + : owner + .setDraining(false) + .pipe( + Effect.andThen(gate.withPermits(1)(Ref.set(current, undefined))), + Effect.andThen(Effect.failCause(cause)), + ), ), ); const fiber = yield* Effect.forkIn(cleanup, scope); @@ -315,98 +259,8 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( yield* Fiber.join(fiber); }).pipe(Effect.mapError((cause) => stackError("shutdown", cause))), ); - const handlers = { - createService: (creation: unknown) => - owner.services - .create(creation) - .pipe(Effect.mapError((cause) => stackError("createService", cause))), - getService: ({ id }: { readonly id: string }) => - owner.services.get(id).pipe(Effect.mapError((cause) => stackError("getService", cause))), - listServices: () => - owner.services.list.pipe(Effect.mapError((cause) => stackError("listServices", cause))), - startService: ({ id }: { readonly id: string }) => - owner.core.start(id).pipe(Effect.mapError((cause) => stackError("startService", cause))), - readyService: ({ id }: { readonly id: string }) => - owner.core.ready(id).pipe(Effect.mapError((cause) => stackError("readyService", cause))), - stopService: ({ id }: { readonly id: string }) => - owner.core.stop(id).pipe(Effect.mapError((cause) => stackError("stopService", cause))), - restartService: ({ id, config }: { readonly id: string; readonly config?: unknown }) => - owner.core - .restart(id, config) - .pipe(Effect.mapError((cause) => stackError("restartService", cause))), - destroyService: ({ id }: { readonly id: string }) => - owner.core - .destroy(id) - .pipe(Effect.mapError((cause) => stackError("destroyService", cause))), - prepareService: ({ id }: { readonly id: string }) => - owner.core - .prepare(id) - .pipe(Effect.mapError((cause) => stackError("prepareService", cause))), - status: ({ id }: { readonly id: string }) => - owner.core.status(id).pipe(Effect.mapError((cause) => stackError("status", cause))), - followStatus: ({ id }: { readonly id: string }) => - owner.core - .followStatus(id) - .pipe(Stream.mapError((cause) => stackError("followStatus", cause))), - logs: ({ id }: { readonly id: string }) => - owner.core.logs(id).pipe( - Stream.map(log), - Stream.mapError((cause) => stackError("logs", cause)), - ), - credentials: ({ id, from }: { readonly id: string; readonly from: "host" | "runtime" }) => - owner - .credentials(id, from) - .pipe(Effect.mapError((cause) => stackError("credentials", cause))), - saveSnapshot: ({ id, key }: { readonly id: string; readonly key: string }) => - owner.snapshots - .saveSnapshot(id, key) - .pipe(Effect.mapError((cause) => stackError("saveSnapshot", cause))), - restoreSnapshot: ({ id, key }: { readonly id: string; readonly key: string }) => - owner.snapshots - .restoreSnapshot(id, key) - .pipe(Effect.mapError((cause) => stackError("restoreSnapshot", cause))), - resetData: ({ id }: { readonly id: string }) => - owner.snapshots - .resetData(id) - .pipe(Effect.mapError((cause) => stackError("resetData", cause))), - supabaseComposition: ({ - services, - reuseIds, - identity, - }: { - readonly services: Parameters[0]; - readonly reuseIds?: NonNullable< - Parameters[1] - >["reuseIds"]; - readonly identity?: NonNullable< - Parameters[1] - >["identity"]; - }) => - owner.composition - .supabase(services, { reuseIds, identity }) - .pipe(Effect.mapError((cause) => stackError("supabaseComposition", cause))), - configureComposition: ( - configuration: Parameters[0], - ) => - owner.composition - .configure(configuration) - .pipe(Effect.mapError((cause) => stackError("configureComposition", cause))), - getComposition: () => - owner.composition.get.pipe( - Effect.mapError((cause) => stackError("getComposition", cause)), - ), - startComposition: () => - owner.composition.start.pipe( - Effect.mapError((cause) => stackError("startComposition", cause)), - ), - stopComposition: () => - owner.composition.stop.pipe( - Effect.mapError((cause) => stackError("stopComposition", cause)), - ), - restartComposition: () => - owner.composition.restart.pipe( - Effect.mapError((cause) => stackError("restartComposition", cause)), - ), + const handlers = StackRpc.of({ + ...owner.handlers, shutdown: ({ destroy }: { readonly destroy: boolean }) => Effect.gen(function* () { const request = yield* Effect.serviceOption(HttpServerRequest.HttpServerRequest); @@ -426,7 +280,7 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( readonly attachmentId: string; readonly bytes: Uint8Array | null; }) => attachments.input(attachmentId, bytes), - }; + }); const rpc = yield* RpcServer.toHttpEffect(StackRpc, { streamBufferSize: 16 }).pipe( Effect.provide(Layer.merge(StackRpc.toLayer(handlers), RpcSerialization.layerNdjson)), ); @@ -472,20 +326,15 @@ export const runStackHost = Effect.fn("StackHost.run")( const dataRootPath = path.join(options.stateRoot, saved.id, "data"); yield* fs.makeDirectory(dataRootPath, { recursive: true }); const dataRoot = yield* fs.realPath(dataRootPath); - if (saved.runtime !== "native") - yield* Container.removeStackContainers({ - engine: saved.runtime, - stackId: saved.id, - root: dataRoot, - }).pipe( - Effect.mapError((cause) => - hostError( - "startup-cleanup", - cause, - cause.reason === "engine-unavailable" ? "runtime-unavailable" : undefined, - ), + yield* Owner.sweepContainers(saved, dataRoot).pipe( + Effect.mapError((cause) => + hostError( + "startup-cleanup", + cause, + cause.reason === "engine-unavailable" ? "runtime-unavailable" : undefined, ), - ); + ), + ); const services = yield* Layer.build( Layer.merge( Owner.layer({ @@ -513,9 +362,6 @@ export const runStackHost = Effect.fn("StackHost.run")( endpoint, acquired.server, acquired.closeConnections, - saved.runtime === "native" - ? undefined - : { engine: saved.runtime, stackId: saved.id, root: dataRoot }, ).pipe( Effect.provideService( CommandRunner.Service, diff --git a/packages/stack/src/State.integration.test.ts b/packages/stack/src/State.integration.test.ts index 532f9cc57b..e095892d32 100644 --- a/packages/stack/src/State.integration.test.ts +++ b/packages/stack/src/State.integration.test.ts @@ -58,7 +58,7 @@ describe("durable stack state", () => { ...current, instances: [ ...current.instances, - { id, creation: { service: "database", config: { version: "17" } } }, + { id, creation: { service: "mail", config: {} } }, ], }); }), @@ -449,6 +449,57 @@ describe("durable stack state", () => { ), ); + it.live("decodes saved compositions, creations, and instance ids strictly", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-typed-" }); + const reported: Array = []; + const store = yield* Layer.build( + State.layer({ + root, + onInvalidState: (id) => Effect.sync(() => reported.push(id)), + }), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + yield* store.save(initial); + const mail = { service: "mail", config: {} }; + const invalid = { + "bad-composition": { composition: { members: "none", dependencies: [] } }, + "bad-creation": { instances: [{ id: "one", creation: { service: "unknown" } }] }, + "duplicate-ids": { + instances: [ + { id: "one", creation: mail }, + { id: "one", creation: mail }, + ], + }, + }; + for (const [id, override] of Object.entries(invalid)) { + yield* fs.makeDirectory(path.join(root, id)); + yield* fs.writeFileString( + path.join(root, id, "state.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + ...initial, + id, + ...override, + }), + ); + } + + expect((yield* store.list).map(({ id }) => id)).toEqual([initial.id]); + expect(reported.toSorted()).toEqual(Object.keys(invalid)); + for (const id of Object.keys(invalid)) { + const failure = yield* store.read(id).pipe(Effect.flip); + expect(failure.operation).toBe("decode"); + expect(failure.message).toContain("Unable to decode state"); + expect(failure.message).toContain(`for stack ${id}`); + } + const duplicate = yield* store.read("duplicate-ids").pipe(Effect.flip); + expect(duplicate.message).toContain("Expected unique instance ids"); + }), + ), + ); + it.effect("cleans up a cancelled Windows replacement and releases the state lock", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/State.ts b/packages/stack/src/State.ts index d2c9c955be..e72feec752 100644 --- a/packages/stack/src/State.ts +++ b/packages/stack/src/State.ts @@ -14,7 +14,9 @@ import { import { rmdir } from "node:fs/promises"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no OS-owned cross-process lock primitive. import { DatabaseSync } from "node:sqlite"; +import { CompositionConfig } from "./Orchestrator.ts"; import { restrictDirectoryToOwner } from "./runtime/postgres-user.ts"; +import { ServiceCreation } from "./services/Catalog.ts"; const SafeId = Schema.String.pipe( Schema.refine((value): value is string => /^[a-zA-Z0-9_-]+$/u.test(value), { @@ -23,11 +25,11 @@ const SafeId = Schema.String.pipe( }), ); -export const SavedInstance = Schema.Struct({ +const SavedInstance = Schema.Struct({ id: SafeId, - creation: Schema.Unknown, + creation: Schema.toCodecJson(ServiceCreation), }); -export interface SavedInstance extends Schema.Schema.Type {} +interface SavedInstance extends Schema.Schema.Type {} export const StackIdentityInput = Schema.Struct({ publishableKey: Schema.optionalKey(Schema.String), @@ -73,8 +75,14 @@ export const SavedStack = Schema.Struct({ stackName: Schema.String, }), runtime: Schema.Literals(["native", "docker", "podman"]), - instances: Schema.Array(SavedInstance), - composition: Schema.Unknown, + instances: Schema.Array(SavedInstance).check( + Schema.makeFilter((instances) => + new Set(instances.map(({ id }) => id)).size === instances.length + ? undefined + : "Expected unique instance ids", + ), + ), + composition: CompositionConfig, credentials: Schema.optionalKey(StackCredentials), ports: Schema.Array(PortClaim), }); diff --git a/packages/stack/src/composition/Supabase.native.integration.test.ts b/packages/stack/src/composition/Supabase.native.integration.test.ts index b26166ff85..f06dc92d39 100644 --- a/packages/stack/src/composition/Supabase.native.integration.test.ts +++ b/packages/stack/src/composition/Supabase.native.integration.test.ts @@ -16,9 +16,9 @@ import { PgClient } from "@effect/sql-pg"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { SignJWT } from "jose"; import { tmpdir } from "node:os"; -import * as Owner from "../Owner.ts"; import * as State from "../State.ts"; import type { SavedStack } from "../State.ts"; +import { ownerFor } from "../../tests/owner-rpc.ts"; import { makeSupabaseComposition, SupabaseCompositionError, @@ -36,21 +36,6 @@ const stateFor = (root: string) => return Context.get(context, State.Service); }); -const ownerFor = (options: { - readonly saved: SavedStack; - readonly state: State.Interface; - readonly root: string; - readonly cacheRoot: string; -}) => { - const { state, ...layerOptions } = options; - return Effect.gen(function* () { - const context = yield* Layer.build( - Owner.layer(layerOptions).pipe(Layer.provide(Layer.succeed(State.Service, state))), - ); - return Context.get(context, Owner.Service); - }); -}; - const query = (url: string, statement: string) => Effect.scoped( Effect.gen(function* () { @@ -123,21 +108,28 @@ it.live("removes a managed SMTP binding when Mail is excluded", () => }, endpoints: { http: { port: "auto" as const } }, }; - const members = yield* owner.composition.supabase([ - auth, - { service: "mail", config: {}, endpoints: { smtp: { port: "auto" } } }, - ]); + const members = yield* owner.rpc.supabaseComposition({ + services: [ + auth, + { service: "mail", config: {}, endpoints: { smtp: { port: "auto" } } }, + ], + }); const instance = members.find(({ creation }) => creation.service === "auth"); if (instance?.creation.service !== "auth") return yield* Effect.die("Auth missing"); expect(instance.creation.config.smtpUrl).toBeDefined(); - const selected = yield* owner.composition.supabase([auth], { reuseIds: [instance.id] }); + const selected = yield* owner.rpc.supabaseComposition({ + services: [auth], + reuseIds: [instance.id], + }); const reused = selected.find(({ id }) => id === instance.id); if (reused?.creation.service !== "auth") return yield* Effect.die("Reused Auth missing"); expect(reused.creation.config.smtpUrl).toBeUndefined(); - const external = yield* owner.composition.supabase( - [{ ...auth, config: { ...auth.config, smtpUrl: "smtp://mail.example:2525" } }], - { reuseIds: [instance.id] }, - ); + const external = yield* owner.rpc.supabaseComposition({ + services: [ + { ...auth, config: { ...auth.config, smtpUrl: "smtp://mail.example:2525" } }, + ], + reuseIds: [instance.id], + }); const configured = external.find(({ id }) => id === instance.id); if (configured?.creation.service !== "auth") return yield* Effect.die("Auth missing"); expect(configured.creation.config.smtpUrl).toBe("smtp://mail.example:2525"); @@ -213,44 +205,46 @@ it.live( yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); const jwtSecret = "catalog-native-auth-storage-secret-with-at-least-32-chars"; - const created = yield* owner.composition.supabase([ - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(jwtSecret), - jwtExpiry: 3600, + const created = yield* owner.rpc.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, }, - endpoints: { sql: { port: "auto" } }, - }, - { - service: "auth", - config: { - databaseUrl: "postgresql://placeholder", - jwtSecret, - jwtExpiry: 3600, + { + service: "auth", + config: { + databaseUrl: "postgresql://placeholder", + jwtSecret, + jwtExpiry: 3600, + }, + endpoints: { http: { port: "auto" } }, }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "storage", - config: { - databaseUrl: "postgresql://placeholder", - filePath: storageRoot, - jwtSecret, + { + service: "storage", + config: { + databaseUrl: "postgresql://placeholder", + filePath: storageRoot, + jwtSecret, + }, + endpoints: { http: { port: "auto" } }, }, - endpoints: { http: { port: "auto" } }, - }, - ]); + ], + }); const database = created.find((entry) => entry.creation.service === "database"); const auth = created.find((entry) => entry.creation.service === "auth"); const storage = created.find((entry) => entry.creation.service === "storage"); if (database === undefined || auth === undefined || storage === undefined) return yield* Effect.die("Native composition members missing"); - yield* owner.composition.start; - const databaseCredentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const databaseCredentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); const databaseUrl = databaseCredentials.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("Native database URL missing"); expect(databaseUrl).toMatch(/^postgresql:\/\/supabase_admin:/u); @@ -265,8 +259,8 @@ it.live( }), ); yield* Context.get(databaseServices, PgClient.PgClient).unsafe("SELECT 1"); - const authCredentials = yield* owner.credentials(auth.id, "host"); - const storageCredentials = yield* owner.credentials(storage.id, "host"); + const authCredentials = yield* owner.rpc.credentials({ id: auth.id, from: "host" }); + const storageCredentials = yield* owner.rpc.credentials({ id: storage.id, from: "host" }); if (authCredentials.url === undefined || storageCredentials.url === undefined) return yield* Effect.die("Native public service URL missing"); @@ -363,20 +357,20 @@ it.live( }, endpoints: { sql: { port: "auto" as const } }, }; - const databaseOnly = yield* owner.composition.supabase([databaseCreation]); + const databaseOnly = yield* owner.rpc.supabaseComposition({ services: [databaseCreation] }); const database = databaseOnly[0]; if (database === undefined) return yield* Effect.die("Database member missing"); - yield* owner.composition.start; - const credentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const credentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); if (credentials.databaseUrl === undefined) return yield* Effect.die("Database credentials missing"); const port = new URL(credentials.databaseUrl).port; yield* query(credentials.databaseUrl, "CREATE TABLE reuse_rows(value text NOT NULL)"); yield* query(credentials.databaseUrl, "INSERT INTO reuse_rows VALUES ('preserved')"); - yield* owner.composition.stop; + yield* owner.rpc.stopComposition(); - const expanded = yield* owner.composition.supabase( - [ + const expanded = yield* owner.rpc.supabaseComposition({ + services: [ databaseCreation, { service: "rest", @@ -384,13 +378,13 @@ it.live( endpoints: { http: { port: "auto" } }, }, ], - { reuseIds: [database.id] }, - ); + reuseIds: [database.id], + }); const reused = expanded.find((entry) => entry.creation.service === "database"); if (reused === undefined) return yield* Effect.die("Reused database missing"); expect(reused.id).toBe(database.id); - yield* owner.composition.start; - const reopened = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const reopened = yield* owner.rpc.credentials({ id: database.id, from: "host" }); if (reopened.databaseUrl === undefined) return yield* Effect.die("Reopened database credentials missing"); expect(new URL(reopened.databaseUrl).port).toBe(port); @@ -398,10 +392,10 @@ it.live( expect(rows).toEqual([{ value: "preserved" }]); const rest = expanded.find((entry) => entry.creation.service === "rest"); if (rest === undefined) return yield* Effect.die("REST missing"); - const before = yield* owner.credentials(rest.id, "host"); - yield* owner.composition.stop; - const withAuth = yield* owner.composition.supabase( - [ + const before = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); + yield* owner.rpc.stopComposition(); + const withAuth = yield* owner.rpc.supabaseComposition({ + services: [ databaseCreation, rest.creation, { @@ -414,13 +408,13 @@ it.live( endpoints: { http: { port: "auto" } }, }, ], - { reuseIds: [database.id, rest.id] }, - ); - yield* owner.composition.start; + reuseIds: [database.id, rest.id], + }); + yield* owner.rpc.startComposition(); const auth = withAuth.find((entry) => entry.creation.service === "auth"); if (auth === undefined) return yield* Effect.die("Auth missing"); - const restAddress = yield* owner.credentials(rest.id, "host"); - const authAddress = yield* owner.credentials(auth.id, "host"); + const restAddress = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); + const authAddress = yield* owner.rpc.credentials({ id: auth.id, from: "host" }); expect(restAddress.url).toBe(before.url); if (restAddress.url === undefined || authAddress.url === undefined) return yield* Effect.die("Shared API URLs missing"); @@ -481,7 +475,9 @@ it.live( }, endpoints: { http: { port: FIXED_STUDIO_PORT } }, }; - const initialMembers = yield* owner.composition.supabase([database, rest, pgmeta, studio]); + const initialMembers = yield* owner.rpc.supabaseComposition({ + services: [database, rest, pgmeta, studio], + }); const databaseId = initialMembers.find( (entry) => entry.creation.service === "database", )?.id; @@ -495,21 +491,23 @@ it.live( studioId === undefined ) return yield* Effect.die("Studio composition members missing"); - yield* owner.composition.start; - yield* owner.composition.stop; - const reduced = yield* owner.composition.supabase([database, rest, pgmeta], { + yield* owner.rpc.startComposition(); + yield* owner.rpc.stopComposition(); + const reduced = yield* owner.rpc.supabaseComposition({ + services: [database, rest, pgmeta], reuseIds: [databaseId, restId, pgmetaId], }); expect(reduced.map((entry) => entry.id).sort()).toEqual( [databaseId, restId, pgmetaId].sort(), ); - yield* owner.composition.start; - yield* owner.composition.stop; - const restored = yield* owner.composition.supabase([database, rest, pgmeta, studio], { + yield* owner.rpc.startComposition(); + yield* owner.rpc.stopComposition(); + const restored = yield* owner.rpc.supabaseComposition({ + services: [database, rest, pgmeta, studio], reuseIds: [databaseId, restId, pgmetaId, studioId], }); expect(restored.find((entry) => entry.creation.service === "studio")?.id).toBe(studioId); - const studioCredentials = yield* owner.credentials(studioId, "host"); + const studioCredentials = yield* owner.rpc.credentials({ id: studioId, from: "host" }); if (studioCredentials.url === undefined) return yield* Effect.die("Studio URL missing"); expect(new URL(studioCredentials.url).port).toBe(String(FIXED_STUDIO_PORT)); }), @@ -544,16 +542,18 @@ it.live( }, endpoints: { sql: { port: "auto" as const } }, }; - const created = yield* owner.composition.supabase([databaseCreation]); + const created = yield* owner.rpc.supabaseComposition({ services: [databaseCreation] }); const database = created[0]; if (database === undefined) return yield* Effect.die("Database member missing"); - yield* owner.composition.start; - const failure = yield* owner.composition - .supabase([databaseCreation], { reuseIds: [database.id] }) + yield* owner.rpc.startComposition(); + const failure = yield* owner.rpc + .supabaseComposition({ services: [databaseCreation], reuseIds: [database.id] }) .pipe(Effect.flip); expect(failure.message).toContain("stopped with wake disabled"); - expect((yield* owner.services.list).map((entry) => entry.id)).toEqual([database.id]); - yield* owner.composition.stop; + expect((yield* state.read(stack.id))?.instances.map((entry) => entry.id)).toEqual([ + database.id, + ]); + yield* owner.rpc.stopComposition(); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 180_000 }, @@ -577,13 +577,13 @@ it.live( }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); const fixedPort = FIXED_MAIL_PORT; - const standaloneMail = yield* owner.services.create({ + const standaloneMail = yield* owner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: fixedPort } }, }); - yield* owner.core.start(standaloneMail.id); - yield* owner.core.ready(standaloneMail.id); + yield* owner.rpc.startService({ id: standaloneMail.id }); + yield* owner.rpc.readyService({ id: standaloneMail.id }); const database = { service: "database" as const, config: { @@ -594,15 +594,19 @@ it.live( }, endpoints: { sql: { port: "auto" as const } }, }; - const failure = yield* owner.composition - .supabase([ - database, - { service: "mail", config: {}, endpoints: { http: { port: fixedPort } } }, - ]) + const failure = yield* owner.rpc + .supabaseComposition({ + services: [ + database, + { service: "mail", config: {}, endpoints: { http: { port: fixedPort } } }, + ], + }) .pipe(Effect.flip); - expect(failure.operation).toBe("supabase"); - expect((yield* owner.services.list).map((entry) => entry.id)).toEqual([standaloneMail.id]); - yield* owner.core.stop(standaloneMail.id); + expect(failure.operation).toBe("supabaseComposition"); + expect((yield* state.read(stack.id))?.instances.map((entry) => entry.id)).toEqual([ + standaloneMail.id, + ]); + yield* owner.rpc.stopService({ id: standaloneMail.id }); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 180_000 }, diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index 76147d0670..e79c7de431 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -18,34 +18,29 @@ interface SupabaseCompositionEntry { readonly creation: ServiceCreation; } -export interface SupabaseCompositionOperations { +/** Owner operations the composition drives; their failures surface as composition errors. */ +export interface SupabaseCompositionOperations { readonly currentComposition: Effect.Effect; - readonly get: (id: string) => Effect.Effect; - readonly status: ( - id: string, - ) => Effect.Effect, SupabaseCompositionError>; - readonly create: ( - creation: ServiceCreation, - ) => Effect.Effect; - readonly destroy: (id: string) => Effect.Effect; - readonly bind: (id: string) => Effect.Effect; + readonly get: (id: string) => Effect.Effect; + readonly status: (id: string) => Effect.Effect, E>; + readonly create: (creation: ServiceCreation) => Effect.Effect; + readonly destroy: (id: string) => Effect.Effect; + readonly bind: (id: string) => Effect.Effect; readonly address: ( id: string, endpoint: string, from: "host" | "runtime", - ) => Effect.Effect; - readonly output: (id: string, name: string) => Effect.Effect; + ) => Effect.Effect; + readonly output: (id: string, name: string) => Effect.Effect; readonly updateCreation: ( id: string, inputs: Record, - ) => Effect.Effect; + ) => Effect.Effect; readonly replaceCreation: ( id: string, creation: ServiceCreation, - ) => Effect.Effect; - readonly configure: ( - configuration: CompositionConfig, - ) => Effect.Effect; + ) => Effect.Effect; + readonly configure: (configuration: CompositionConfig) => Effect.Effect; } export interface SupabaseCompositionOptions { @@ -63,8 +58,8 @@ const compositionErrorFrom = (cause: unknown) => : compositionError(cause instanceof Error ? cause.message : String(cause), cause); export const makeSupabaseComposition = Effect.fn("Supabase.compose")( - ( - operations: SupabaseCompositionOperations, + ( + operations: SupabaseCompositionOperations, inputs: ReadonlyArray, options: SupabaseCompositionOptions = {}, ) => @@ -332,7 +327,10 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( .bind(entry.id) .pipe( Effect.mapError((cause) => - compositionError(`${entry.creation.service} ${entry.id}: ${cause.message}`, cause), + compositionError( + `${entry.creation.service} ${entry.id}: ${compositionErrorFrom(cause).message}`, + cause, + ), ), ); } diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index d107204cec..38dedaf7c3 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -32,15 +32,8 @@ import type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; import * as State from "./State.ts"; import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; +import { StackError, StackRpc, type Definition, type Observation } from "./Rpc.ts"; import { - StackError, - StackErrorSchema, - StackRpc, - type Definition, - type Observation, -} from "./Rpc.ts"; -import { - ServiceCreation as ServiceCreationSchema, ServiceCreationInput as ServiceCreationInputSchema, type ServiceCreation, type ServiceCreationInput as CatalogServiceCreationInput, @@ -104,8 +97,8 @@ export interface OpenOptions extends StackLocations { } const failure = (operation: string, cause: unknown): StackError => - Schema.is(StackErrorSchema)(cause) - ? new StackError(cause) + Schema.is(StackError)(cause) + ? cause : new StackError({ operation, message: Schema.is(RpcClientError)(cause) @@ -516,7 +509,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( ), Effect.catchIf( (cause) => - Schema.is(StackErrorSchema)(cause) && + Schema.is(StackError)(cause) && cause.operation === "command-input-closed", () => Effect.void, ), @@ -579,16 +572,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( if (current === undefined) return yield* failure("definition", "Stack does not exist"); return current; }).pipe(Effect.mapError((cause) => failure("definition", cause))); - const definitions = savedDefinition.pipe( - Effect.flatMap((current) => - Effect.forEach(current.instances, (entry) => - Schema.decodeUnknownEffect(Schema.toCodecJson(ServiceCreationSchema))(entry.creation).pipe( - Effect.map((creation) => ({ id: entry.id, creation })), - Effect.mapError((cause) => failure("definition", cause)), - ), - ), - ), - ); + const definitions = savedDefinition.pipe(Effect.map((current) => current.instances)); return { id: saved.id, services: { @@ -598,7 +582,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( Effect.flatMap((entries) => { const definition = entries.find((entry) => entry.id === id); return definition === undefined - ? Effect.fail(failure("getService", `Unknown service ${id}`)) + ? Effect.fail(failure("service", `Unknown service ${id}`)) : Effect.succeed(instance(definition)); }), ), @@ -624,12 +608,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( ).pipe(Effect.map((definitions) => definitions.map(instance))), configure: (config: Orchestrator.CompositionConfig) => call("configureComposition", (rpc) => rpc.configureComposition(config)), - describe: savedDefinition.pipe( - Effect.flatMap((current) => - Schema.decodeUnknownEffect(Orchestrator.CompositionConfig)(current.composition), - ), - Effect.mapError((cause) => failure("getComposition", cause)), - ), + describe: savedDefinition.pipe(Effect.map((current) => current.composition)), start: call("startComposition", (rpc) => rpc.startComposition()), stop: call("stopComposition", (rpc) => rpc.stopComposition()), restart: call("restartComposition", (rpc) => rpc.restartComposition()), diff --git a/packages/stack/src/host/Credentials.ts b/packages/stack/src/host/Credentials.ts new file mode 100644 index 0000000000..e623fc53c4 --- /dev/null +++ b/packages/stack/src/host/Credentials.ts @@ -0,0 +1,203 @@ +import { Data, Effect, Redacted, Schema } from "effect"; +import { + DEFAULT_LOCAL_DATABASE_PASSWORD, + DEFAULT_LOCAL_JWT_SECRET, + DEFAULT_POSTGRES_ROOT_KEY, +} from "../Defaults.ts"; +import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; +import { resolveStackIdentity } from "../services/ServiceConfig.ts"; +import type { SavedStack, StackCredentials, StackIdentityInput } from "../State.ts"; + +export class CredentialError extends Data.TaggedError("CredentialError")<{ + readonly message: string; +}> {} + +type Overrides = Partial< + Pick +>; + +/** Reports whether a creation receives the stack-wide credential record. */ +export const consumesCredentials = (creation: ServiceCreationInput): boolean => { + switch (creation.service) { + case "database": + case "auth": + case "realtime": + case "storage": + case "functions": + case "studio": + case "pooler": + return true; + case "rest": + return creation.config.jwks === undefined || creation.config.jwtSecret !== undefined; + default: + return false; + } +}; + +const overridesFor = (creation: ServiceCreationInput): Overrides => { + if (creation.service === "database") + return { + ...(creation.config.jwtSecret === undefined + ? {} + : { jwtSecret: Redacted.value(creation.config.jwtSecret) }), + ...(creation.config.rootKey === undefined + ? {} + : { postgresRootKey: Redacted.value(creation.config.rootKey) }), + ...(creation.config.databasePassword === undefined + ? {} + : { databasePassword: Redacted.value(creation.config.databasePassword) }), + }; + return "jwtSecret" in creation.config && creation.config.jwtSecret !== undefined + ? { jwtSecret: creation.config.jwtSecret } + : {}; +}; + +/** Collects explicit credential values, rejecting creations that disagree with each other. */ +export const credentialOverrides = ( + creations: ReadonlyArray, +): Effect.Effect => { + const overrides: Record = {}; + for (const creation of creations) + for (const [key, value] of Object.entries(overridesFor(creation))) { + if (overrides[key] !== undefined && overrides[key] !== value) + return Effect.fail( + new CredentialError({ + message: `Credential override ${key} conflicts within the stack composition`, + }), + ); + overrides[key] = value; + } + return Effect.succeed(overrides); +}; + +/** Drops the stack credential record once no saved instance consumes it. */ +export const withoutUnusedCredentials = (saved: SavedStack): SavedStack => { + if ( + saved.credentials === undefined || + saved.instances.some(({ creation }) => consumesCredentials(creation)) + ) + return saved; + const withoutCredentials = { ...saved }; + delete withoutCredentials.credentials; + return withoutCredentials; +}; + +const identityChanged = (saved: StackCredentials, next: StackCredentials) => + next.jwtSecret !== saved.jwtSecret || + next.publishableKey !== saved.publishableKey || + next.secretKey !== saved.secretKey || + next.anonKey !== saved.anonKey || + next.serviceRoleKey !== saved.serviceRoleKey || + next.jwks !== saved.jwks || + next.gotrueJwtKeys !== saved.gotrueJwtKeys || + next.remoteJwks !== saved.remoteJwks || + next.anonKeyIsOverride !== saved.anonKeyIsOverride || + next.serviceRoleKeyIsOverride !== saved.serviceRoleKeyIsOverride; + +/** + * Resolves the stack credential record, generating one only while no saved instance consumes it; + * it returns the saved record itself when nothing changes. + */ +export const nextCredentials = Effect.fn("Credentials.next")(function* ( + current: Pick, + overrides: Overrides, + identity: StackIdentityInput | undefined, +) { + const saved = current.credentials; + if (saved === undefined) { + if (current.instances.some(({ creation }) => consumesCredentials(creation))) + return yield* new CredentialError({ + message: "Saved instances have no stack credential record; refusing to infer credentials", + }); + const jwtSecret = overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET; + return { + jwtSecret, + postgresRootKey: overrides.postgresRootKey ?? DEFAULT_POSTGRES_ROOT_KEY, + databasePassword: overrides.databasePassword ?? DEFAULT_LOCAL_DATABASE_PASSWORD, + ...(yield* resolveStackIdentity(jwtSecret, identity, undefined)), + } satisfies StackCredentials; + } + const conflict = + (overrides.postgresRootKey !== undefined && + overrides.postgresRootKey !== saved.postgresRootKey && + "rootKey") || + (overrides.databasePassword !== undefined && + overrides.databasePassword !== saved.databasePassword && + "databasePassword") || + (identity === undefined && + overrides.jwtSecret !== undefined && + overrides.jwtSecret !== saved.jwtSecret && + "jwtSecret"); + if (conflict !== false) + return yield* new CredentialError({ + message: `Credential override ${conflict} conflicts with the saved stack value`, + }); + const jwtSecret = + identity === undefined ? saved.jwtSecret : (overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET); + const next: StackCredentials = { + ...saved, + jwtSecret, + ...(yield* resolveStackIdentity(jwtSecret, identity, saved)), + }; + return identityChanged(saved, next) ? next : saved; +}); + +/** Completes a creation with the stack credentials it consumes. */ +export const withCredentials = ( + creation: ServiceCreationInput, + credentials: StackCredentials, +): Effect.Effect => { + const config: Record = { ...creation.config }; + switch (creation.service) { + case "database": + Object.assign(config, { + databasePassword: Redacted.make(credentials.databasePassword), + jwtSecret: Redacted.make(credentials.jwtSecret), + rootKey: Redacted.make(credentials.postgresRootKey), + }); + break; + case "auth": + Object.assign(config, { + jwtSecret: credentials.jwtSecret, + gotrueJwtKeys: creation.config.gotrueJwtKeys ?? credentials.gotrueJwtKeys, + }); + break; + case "rest": + case "realtime": + Object.assign(config, { + jwtSecret: credentials.jwtSecret, + jwks: creation.config.jwks ?? credentials.jwks, + }); + break; + case "storage": + Object.assign(config, { + jwtSecret: credentials.jwtSecret, + jwks: creation.config.jwks ?? credentials.jwks, + anonKey: creation.config.anonKey ?? credentials.anonKey, + serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, + }); + break; + case "functions": + Object.assign(config, { + jwtSecret: credentials.jwtSecret, + jwks: creation.config.jwks ?? credentials.jwks, + anonKey: creation.config.anonKey ?? credentials.anonKey, + serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, + publishableKey: creation.config.publishableKey ?? credentials.publishableKey, + secretKey: creation.config.secretKey ?? credentials.secretKey, + }); + break; + case "studio": + Object.assign(config, { + jwtSecret: credentials.jwtSecret, + anonKey: creation.config.anonKey ?? credentials.anonKey, + serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, + publishableKey: creation.config.publishableKey ?? credentials.publishableKey, + secretKey: creation.config.secretKey ?? credentials.secretKey, + }); + break; + default: + Object.assign(config, { jwtSecret: credentials.jwtSecret }); + } + return Schema.decodeUnknownEffect(ServiceCreation)({ ...creation, config }); +}; diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index bd8515dde3..2009043c0d 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -1,5 +1,8 @@ import { allowedEndpointNames, type ServiceCreation } from "../services/Catalog.ts"; -import { Data, Effect } from "effect"; +import type { ServiceEndpoint } from "../services/Recipe.ts"; +import type { NetworkEndpoint } from "../Network.ts"; +import { ProxyError, type BackendAddress } from "../Proxy.ts"; +import { Data, Effect, Redacted } from "effect"; export class EndpointError extends Data.TaggedError("EndpointError")<{ readonly message: string; @@ -41,6 +44,58 @@ export const apiRoute = (service: ServiceCreation["service"]): string | undefine } }; +type RouteKeys = NonNullable[number]["keyRewrite"]>["keys"]; + +/** Routes a service's HTTP endpoint on the shared API listener, or `undefined` for a dedicated one. */ +export const sharedRoutes = ( + creation: ServiceCreation, + name: string, + keys: RouteKeys, +): NetworkEndpoint["shared"] => { + const route = name === "http" ? apiRoute(creation.service) : undefined; + if (route === undefined) return undefined; + switch (creation.service) { + case "realtime": + return [ + { + prefix: "/realtime/v1/api", + upstreamPrefix: "/api", + upstreamHost: "realtime-dev", + keyRewrite: { policy: "bearer", keys }, + }, + { + prefix: route, + upstreamPrefix: "/socket", + upstreamHost: "realtime-dev", + keyRewrite: { policy: "query", keys }, + }, + ]; + case "storage": + return [ + { prefix: `${route}/s3`, upstreamPrefix: "/s3" }, + { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, + ]; + case "rest": + case "auth": + return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }]; + case "functions": + return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "sb-api-key", keys } }]; + default: + return [{ prefix: route, upstreamPrefix: "/" }]; + } +}; + +/** Translates a launched runtime's endpoint into the proxy's backend address. */ +export const backendAddress = ( + endpoint: ServiceEndpoint, +): Effect.Effect => { + if (endpoint.kind === "unix") + return endpoint.path === undefined + ? Effect.fail(new ProxyError({ message: "Unix endpoint has no path" })) + : Effect.succeed({ path: `${endpoint.path}/.s.PGSQL.${endpoint.port}` }); + return Effect.succeed({ host: endpoint.host ?? "127.0.0.1", port: endpoint.port }); +}; + export const publicUrl = (host: string, port: number) => `http://${host}:${port}`; const postgresUrl = ( @@ -57,21 +112,25 @@ interface EndpointAddress { readonly port: number; } -export type EndpointAddressFor = ( +export type EndpointAddressFor = ( endpoint: string, from: "host" | "runtime", -) => Effect.Effect; +) => Effect.Effect; -export type EndpointPassword = () => Effect.Effect; +const databasePassword = (creation: ServiceCreation): Effect.Effect => + creation.service === "database" + ? Effect.succeed(Redacted.value(creation.config.databasePassword)) + : Effect.fail(new EndpointError({ message: "Database URLs require a database" })); -export const outputsFor = ( +/** Renders dependency outputs; `current` supplies the saved creation at resolution time. */ +export const outputsFor = ( creation: ServiceCreation, - address: EndpointAddressFor, - password: EndpointPassword, -): Readonly>> => { + current: Effect.Effect, + address: EndpointAddressFor, +): Readonly>> => { const output = (name: string) => address(name, "runtime").pipe(Effect.map(({ host, port }) => publicUrl(host, port))); - const outputs: Record> = {}; + const outputs: Record> = {}; if (endpointNames(creation).includes("http")) { outputs.url = output("http"); outputs.hostUrl = address("http", "host").pipe( @@ -93,54 +152,53 @@ export const outputsFor = ( ] as const) outputs[name] = address("sql", "runtime").pipe( Effect.flatMap(({ host, port }) => - password().pipe(Effect.map((value) => postgresUrl(host, port, role, value, database))), + current.pipe( + Effect.flatMap(databasePassword), + Effect.map((value) => postgresUrl(host, port, role, value, database)), + ), ), ); } return outputs; }; -export const credentialsFor = Effect.fn("Endpoints.credentialsFor")( - ( - creation: ServiceCreation, - address: EndpointAddressFor, - password: EndpointPassword, - from: "host" | "runtime", - ): Effect.Effect>, EndpointError> => - Effect.gen(function* () { - const credentials: Record = {}; - if (endpointNames(creation).includes("http")) { - const { host, port } = yield* address("http", from); - const origin = publicUrl(host, port); - credentials.url = `${origin}${apiRoute(creation.service) ?? ""}`; - if (apiRoute(creation.service) !== undefined) credentials.apiUrl = origin; - } - if (creation.service === "mail") { - if (endpointNames(creation).includes("smtp")) { - const { host, port } = yield* address("smtp", from); - credentials.smtpUrl = `smtp://${host}:${port}`; - } - if (endpointNames(creation).includes("pop3")) { - const { host, port } = yield* address("pop3", from); - credentials.pop3Url = `pop3://${host}:${port}`; - } - } - if (creation.service === "pooler" && endpointNames(creation).includes("sql")) { - const { host, port } = yield* address("sql", from); - credentials.sqlUrl = `postgresql://${host}:${port}`; - } - if (creation.service === "database" && endpointNames(creation).includes("sql")) { - const { host, port } = yield* address("sql", from); - const value = yield* password(); - for (const [name, role, database] of [ - ["databaseUrl", "supabase_admin", "postgres"], - ["authenticatorUrl", "authenticator", "postgres"], - ["authDatabaseUrl", "supabase_auth_admin", "postgres"], - ["storageDatabaseUrl", "supabase_storage_admin", "postgres"], - ["internalDatabaseUrl", "supabase_admin", "_supabase"], - ] as const) - credentials[name] = postgresUrl(host, port, role, value, database); - } - return credentials; - }), -); +export const credentialsFor = Effect.fn("Endpoints.credentialsFor")(function* ( + creation: ServiceCreation, + address: EndpointAddressFor, + from: "host" | "runtime", +) { + const credentials: Record = {}; + if (endpointNames(creation).includes("http")) { + const { host, port } = yield* address("http", from); + const origin = publicUrl(host, port); + credentials.url = `${origin}${apiRoute(creation.service) ?? ""}`; + if (apiRoute(creation.service) !== undefined) credentials.apiUrl = origin; + } + if (creation.service === "mail") { + if (endpointNames(creation).includes("smtp")) { + const { host, port } = yield* address("smtp", from); + credentials.smtpUrl = `smtp://${host}:${port}`; + } + if (endpointNames(creation).includes("pop3")) { + const { host, port } = yield* address("pop3", from); + credentials.pop3Url = `pop3://${host}:${port}`; + } + } + if (creation.service === "pooler" && endpointNames(creation).includes("sql")) { + const { host, port } = yield* address("sql", from); + credentials.sqlUrl = `postgresql://${host}:${port}`; + } + if (creation.service === "database" && endpointNames(creation).includes("sql")) { + const { host, port } = yield* address("sql", from); + const value = yield* databasePassword(creation); + for (const [name, role, database] of [ + ["databaseUrl", "supabase_admin", "postgres"], + ["authenticatorUrl", "authenticator", "postgres"], + ["authDatabaseUrl", "supabase_auth_admin", "postgres"], + ["storageDatabaseUrl", "supabase_storage_admin", "postgres"], + ["internalDatabaseUrl", "supabase_admin", "_supabase"], + ] as const) + credentials[name] = postgresUrl(host, port, role, value, database); + } + return credentials; +}); diff --git a/packages/stack/src/internal/failure-message.ts b/packages/stack/src/internal/failure-message.ts new file mode 100644 index 0000000000..fb483cce5e --- /dev/null +++ b/packages/stack/src/internal/failure-message.ts @@ -0,0 +1,13 @@ +/** Describes a failure as a string; Effect errors such as `Cause.UnknownError` can lack a message. */ +export const failureMessage = (cause: unknown): string => { + if (!(cause instanceof Error)) return String(cause); + const visited = new Set(); + let current: unknown = cause; + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + const message: unknown = current.message; + if (typeof message === "string" && message.length > 0) return message; + current = current.cause; + } + return current === undefined || current instanceof Error ? cause.name : failureMessage(current); +}; diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index 271ea47978..b7336d3e4b 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -33,8 +33,6 @@ import { makeProcessRecipe, type ProcessDependencies } from "./ProcessRecipe.ts" import { slimImageMirrors, type ServiceKind } from "../Artifacts.ts"; import type { ServiceInstanceContext } from "../Service.ts"; -export type { CatalogLog } from "./Recipe.ts"; - const endpointSchemas = [ ["database", DatabaseEndpoints], ["rest", Rest.Endpoints], diff --git a/packages/stack/src/services/Rest.integration.test.ts b/packages/stack/src/services/Rest.integration.test.ts index d8b156eddf..01290e532a 100644 --- a/packages/stack/src/services/Rest.integration.test.ts +++ b/packages/stack/src/services/Rest.integration.test.ts @@ -57,7 +57,7 @@ describe("service catalog", () => { identity: { projectRoot: root, branchContext: "test", stackName: "catalog" }, runtime: "docker", instances: [], - composition: {}, + composition: { members: [], dependencies: [] }, ports: [], }); const network = yield* makeTestNetwork({ stackId, runtime: "docker", state }); diff --git a/packages/stack/src/shutdown.integration.test.ts b/packages/stack/src/shutdown.integration.test.ts index b94788cae3..b06d1c5405 100644 --- a/packages/stack/src/shutdown.integration.test.ts +++ b/packages/stack/src/shutdown.integration.test.ts @@ -5,7 +5,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { fileURLToPath } from "node:url"; import * as PromiseStack from "./index.ts"; import { HostEndpoint } from "./HostProcess.ts"; -import { StackErrorSchema } from "./Rpc.ts"; +import { StackError } from "./Rpc.ts"; import * as State from "./State.ts"; import { open as openEffect } from "./effect.ts"; @@ -120,9 +120,8 @@ it.live("Effect stop reports shutdown-exit when the acknowledged owner remains a return yield* Effect.flip(stack.stop); }), ); - expect(Schema.is(StackErrorSchema)(error)).toBe(true); - if (!Schema.is(StackErrorSchema)(error)) - return yield* Effect.die("unexpected shutdown error"); + expect(Schema.is(StackError)(error)).toBe(true); + if (!Schema.is(StackError)(error)) return yield* Effect.die("unexpected shutdown error"); expect(error.operation).toBe("shutdown-exit"); expect(error.message).toContain("shutdown acknowledgement"); }), @@ -145,9 +144,8 @@ it.live("Promise destroy waits for owner disappearance after the RPC acknowledge (stack) => Effect.tryPromise(() => stack.close()), ), ); - expect(Schema.is(StackErrorSchema)(error)).toBe(true); - if (!Schema.is(StackErrorSchema)(error)) - return yield* Effect.die("unexpected shutdown error"); + expect(Schema.is(StackError)(error)).toBe(true); + if (!Schema.is(StackError)(error)) return yield* Effect.die("unexpected shutdown error"); expect(error).toMatchObject({ operation: "shutdown-exit" }); expect(error.message).toContain("shutdown acknowledgement"); }), diff --git a/packages/stack/tests/owner-rpc.ts b/packages/stack/tests/owner-rpc.ts new file mode 100644 index 0000000000..fd8d254423 --- /dev/null +++ b/packages/stack/tests/owner-rpc.ts @@ -0,0 +1,25 @@ +import { Context, Effect, Layer } from "effect"; +import { RpcTest } from "effect/unstable/rpc"; +import * as Owner from "../src/Owner.ts"; +import { OwnerRpc } from "../src/Rpc.ts"; +import { Service as StateService } from "../src/State.ts"; +import type { Interface as StateInterface, SavedStack } from "../src/State.ts"; + +/** Builds an in-process owner and an RPC client bound to its handlers. */ +export const ownerFor = (options: { + readonly saved: SavedStack; + readonly state: StateInterface; + readonly root: string; + readonly cacheRoot: string; +}) => + Effect.gen(function* () { + const { state, ...layerOptions } = options; + const context = yield* Layer.build( + Owner.layer(layerOptions).pipe(Layer.provide(Layer.succeed(StateService, state))), + ); + const owner = Context.get(context, Owner.Service); + const rpc = yield* RpcTest.makeClient(OwnerRpc).pipe( + Effect.provide(OwnerRpc.toLayer(owner.handlers)), + ); + return { rpc, namespace: owner.namespace, getStackCredentials: owner.getStackCredentials }; + }); diff --git a/packages/stack/tests/state-lock-fixture.ts b/packages/stack/tests/state-lock-fixture.ts index deed86a674..d78b5d0e24 100644 --- a/packages/stack/tests/state-lock-fixture.ts +++ b/packages/stack/tests/state-lock-fixture.ts @@ -28,7 +28,7 @@ const program = Effect.scoped( return yield* Effect.die("Missing writer state"); yield* state.save({ ...saved, - instances: [...saved.instances, { id, creation: {} }], + instances: [...saved.instances, { id, creation: { service: "mail", config: {} } }], }); return; } From 97c71e779e14c190f118ed2c05741a2ad418a184 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 12:21:40 +0000 Subject: [PATCH 12/71] refactor(stack): unify the database snapshot protocol across engines (#6837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The snapshot protocol existed twice: in TypeScript for native data, and in an embedded shell script for Docker volumes. The protocol covers staging, retiring the old copy, publishing, rollback, retention and the descriptor check. Every fix had to land in both copies. The shell copy also depended, unchecked, on binaries from an externally built image. ## Change - One protocol module owns naming, staging, rollback, retention, descriptor checks and ready-marker publication. - The native engine runs its steps on the host. - The Docker engine compiles each operation into one helper script, so each operation is still a single exec. - Both engines hold a host-side lock and an in-script lock. Retired generations are recovered, and data and its ready marker are published atomically. - A restore creates the data directory's parent, so a fresh Docker instance can restore before its first start (the warm `db diff` shadow path). - Uses of one Docker helper run concurrently, and closes are guarded by a per-open generation. - The unreachable recursive fallback copy is deleted. - One contract suite runs the same scenarios against both engines. - Runtime session wrapping is shared between process recipes and the database, and old artifact-cache temp entries are reaped. ## Stack Part 4 of 8 of the stack package simplification, based on #6836. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines ← this PR 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API --------- Co-authored-by: Claude Opus 5.5 --- .../db/diff/diff.stack-cache.e2e.test.ts | 1 + ...5-ephemeral-postgres-for-schema-tooling.md | 2 +- docs/adr/README.md | 7 +- .../stack/src/preparation/ArtifactStore.ts | 64 +- .../preparation/artifacts.integration.test.ts | 42 + packages/stack/src/runtime/Session.ts | 120 +++ .../stack/src/runtime/Session.unit.test.ts | 18 + packages/stack/src/services/Database.ts | 100 +- .../DatabaseSnapshot.integration.test.ts | 901 ++++++++++++------ .../stack/src/services/DatabaseSnapshot.ts | 766 ++++++++------- packages/stack/src/services/ProcessRecipe.ts | 67 +- .../storage/DirectoryCopy.integration.test.ts | 11 +- packages/stack/src/storage/DirectoryCopy.ts | 216 +---- .../DockerDatabaseStorage.integration.test.ts | 2 - .../src/storage/DockerDatabaseStorage.ts | 204 +--- .../stack/src/storage/DockerHelperRegistry.ts | 120 ++- .../storage/DockerHelperRegistry.unit.test.ts | 140 ++- .../src/storage/DockerSnapshotBackend.ts | 127 +++ 18 files changed, 1708 insertions(+), 1200 deletions(-) create mode 100644 packages/stack/src/runtime/Session.ts create mode 100644 packages/stack/src/runtime/Session.unit.test.ts create mode 100644 packages/stack/src/storage/DockerSnapshotBackend.ts diff --git a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts index cc13b717ef..c0518adc45 100644 --- a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts @@ -191,6 +191,7 @@ describe("supabase db diff (stack shadow baseline cache)", () => { home, ); assertSuccess(second, `${runtime} warm db diff`); + expect(second.stderr).not.toMatch(/baseline (?:unusable|not cached)/iu); expect(second.stdout).toMatch(/stack_cache_second/iu); expect(second.stdout).not.toMatch(/stack_cache_first/iu); diff --git a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md index f75180d5d5..0b55385e27 100644 --- a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md +++ b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md @@ -1,6 +1,6 @@ # 0025. Ephemeral Postgres for schema tooling -**Status**: proposed +**Status**: superseded by throwaway stack shadow stacks and [database snapshots](../../packages/stack/ARCHITECTURE.md#snapshots-belong-to-the-database-instance) — `EphemeralPostgres` was never built; see [`stack-shadow.ts`](../../apps/cli/src/command-internal/stack-shadow.ts) **Date**: 2026-09-09 ## Problem Statement diff --git a/docs/adr/README.md b/docs/adr/README.md index b25e8af88b..27eb9beae9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -53,11 +53,12 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 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 | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | accepted | | 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | accepted | +| 0014 | [macOS Code Signing & Notarization](0014-macos-code-signing-and-notarization.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 | +| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | superseded | | 0018 | [Sparse Config Subtraction](0018-sparse-config-subtraction.md) | proposed | | 0019 | [Raw API-Response Passthrough on API-Sourced Config](0019-config-api-response-passthrough.md) | accepted | | 0020 | [Config Naming Vocabulary](0020-config-naming-vocabulary.md) | accepted | @@ -65,7 +66,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0022 | [Config Diff Classification and Managed Surface](0022-config-diff-classification-and-managed-surface.md) | accepted | | 0023 | [Config Pull Write Strategy and Scope Resolution](0023-config-pull-write-strategy-and-scope-resolution.md) | accepted | | 0024 | [Top-Level `pull` Orchestration](0024-top-level-pull-orchestration.md) | accepted | -| 0025 | [Ephemeral Postgres for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | proposed | +| 0025 | [Ephemeral Postgres for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | superseded | | 0026 | [Slim Image and Native Artifact Mirrors](0026-slim-artifact-mirrors.md) | proposed | ## Template diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 2421c4bda3..2b8aaf2dd0 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -1,4 +1,14 @@ -import { Crypto, Effect, FileSystem, Option, Path, PlatformError, Predicate, Schema } from "effect"; +import { + Clock, + Crypto, + Effect, + FileSystem, + Option, + Path, + PlatformError, + Predicate, + Schema, +} from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, PreparationError } from "./Errors.ts"; @@ -585,6 +595,57 @@ const cleanup = (fs: FileSystem.FileSystem, path: string): Effect.Effect { + const prefix = `.${targetName}.`; + const suffix = [".tmp", ".invalid"].find((candidate) => name.endsWith(candidate)); + return ( + suffix !== undefined && + name.startsWith(prefix) && + leftoverToken.test(name.slice(prefix.length, name.length - suffix.length)) + ); +}; + +/** + * Best-effort removal of one target's temp/quarantine siblings that a hard kill left behind. + * Only entries older than `orphanMaxAgeMillis` are removed, so a concurrent in-progress download + * by another process is never touched. + */ +const reapLeftoversOf = ( + fs: FileSystem.FileSystem, + path: Path.Path, + target: string, +): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const parent = path.dirname(target); + const targetName = path.basename(target); + const names = yield* fs.readDirectory(parent); + yield* Effect.forEach( + names.filter((name) => isLeftoverOf(targetName, name)), + (name) => { + const candidate = path.join(parent, name); + return fs.stat(candidate).pipe( + Effect.flatMap((info) => + Option.match(info.mtime, { + onNone: () => Effect.void, + onSome: (mtime) => + now - mtime.getTime() > orphanMaxAgeMillis + ? fs.remove(candidate, { recursive: true, force: true }) + : Effect.void, + }), + ), + Effect.ignore, + ); + }, + { discard: true }, + ); + }).pipe(Effect.ignore); + const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( fs: FileSystem.FileSystem, path: Path.Path, @@ -599,6 +660,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const target = path.resolve(cacheRoot, request.key); const targetParent = path.dirname(target); yield* ensureDirectory(fs, path, targetParent, cacheRoot); + yield* reapLeftoversOf(fs, path, target); const metadataPath = path.join(target, METADATA_NAME); const checkCached: Effect.Effect< Option.Option, diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 92b1c19194..28a4a6bab6 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Crypto, + DateTime, Deferred, Effect, Exit, @@ -748,3 +749,44 @@ describe("verified native artifact preparation", () => { ), ); }); + +describe("orphaned temp/quarantine sweep", () => { + it.live("removes a prepared key's old leftovers and keeps fresh and unrelated ones", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-sweep-", + }); + const now = yield* DateTime.now; + const old = DateTime.toDate(DateTime.subtract(now, { hours: 25 })); + const recent = DateTime.toDate(DateTime.subtract(now, { hours: 1 })); + const parent = `${root}/database`; + const oldTemp = `${parent}/.postgres.00000000-0000-4000-8000-000000000001.tmp`; + const oldQuarantine = `${parent}/.postgres.00000000-0000-4000-8000-000000000002.invalid`; + const freshTemp = `${parent}/.postgres.00000000-0000-4000-8000-000000000003.tmp`; + const freshQuarantine = `${parent}/.postgres.00000000-0000-4000-8000-000000000004.invalid`; + const unrelated = `${parent}/nested/.x.tmp`; + const leftovers = [oldTemp, oldQuarantine, freshTemp, freshQuarantine, unrelated]; + for (const leftover of leftovers) { + yield* fs.makeDirectory(leftover, { recursive: true }); + yield* fs.writeFileString(`${leftover}/marker`, "leftover"); + } + for (const leftover of [oldTemp, oldQuarantine, unrelated]) + yield* fs.utimes(leftover, old, old); + for (const leftover of [freshTemp, freshQuarantine]) + yield* fs.utimes(leftover, recent, recent); + + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + expect(yield* fs.exists(oldTemp), "construction leaves the cache alone").toBe(true); + expect((yield* store.prepare(request)).outcome).toBe("downloaded"); + + expect(yield* fs.exists(oldTemp)).toBe(false); + expect(yield* fs.exists(oldQuarantine)).toBe(false); + expect(yield* fs.exists(freshTemp)).toBe(true); + expect(yield* fs.exists(freshQuarantine)).toBe(true); + expect(yield* fs.exists(unrelated)).toBe(true); + }), + ), + ); +}); diff --git a/packages/stack/src/runtime/Session.ts b/packages/stack/src/runtime/Session.ts new file mode 100644 index 0000000000..bc27d3bd44 --- /dev/null +++ b/packages/stack/src/runtime/Session.ts @@ -0,0 +1,120 @@ +import { Cause, Effect, Exit, Fiber, PubSub, Ref, Scope, Stream } from "effect"; +import { failureMessage } from "../internal/failure-message.ts"; +import { ServiceError, type RuntimeSession } from "../Service.ts"; +import type { ContainerProcess } from "./Container.ts"; + +/** Wraps an arbitrary failure as a `ServiceError`, special-casing Effect timeouts for a clearer message. */ +export const mapToServiceError = (operation: string, cause: unknown): ServiceError => + cause instanceof ServiceError + ? cause + : new ServiceError({ + operation, + message: Cause.isTimeoutError(cause) + ? `Service ${operation} timed out` + : failureMessage(cause), + cause, + }); + +/** A descendant outside the process group can keep stderr open after the launcher exits. */ +const stderrTailReady = (drained: Fiber.Fiber) => + Fiber.await(drained).pipe( + Effect.asVoid, + Effect.raceFirst(Effect.sleep("1 second")), + Effect.ignore, + ); + +/** + * Settles a runtime's exit as a `ServiceError` exit, optionally waiting briefly for a captured + * stderr tail to drain and folding it into the failure message. + */ +export const processExit = ( + exitCode: Effect.Effect, + describe: (code: number) => string, + stderr?: { + readonly tail: Ref.Ref; + readonly drained: Fiber.Fiber; + }, +): Effect.Effect> => + (stderr === undefined + ? exitCode + : exitCode.pipe(Effect.tap(() => stderrTailReady(stderr.drained))) + ).pipe( + Effect.flatMap((code) => + Number(code) === 0 + ? Effect.void + : (stderr === undefined ? Effect.succeed("") : Ref.get(stderr.tail)).pipe( + Effect.flatMap((text) => { + const detail = text.trim(); + return Effect.fail( + new ServiceError({ + operation: "exit", + message: + detail.length === 0 + ? describe(Number(code)) + : `${describe(Number(code))}: ${detail}`, + }), + ); + }), + ), + ), + Effect.mapError((cause) => mapToServiceError("exit", cause)), + Effect.exit, + ); + +/** + * Forks stdout/stderr drains that publish to `logs`; returns the stderr fiber so callers can await + * drain completion (e.g. before finalizing exit). Optionally captures a rolling stderr tail for + * folding into exit failure messages. + */ +export const publishProcessLogs = ( + process: { + readonly stdout: Stream.Stream; + readonly stderr: Stream.Stream; + }, + logs: PubSub.PubSub<{ readonly stream: "stdout" | "stderr"; readonly bytes: Uint8Array }>, + scope: Scope.Closeable, + stderrTail?: Ref.Ref, +): Effect.Effect> => { + const drain = (stream: Stream.Stream, name: "stdout" | "stderr") => { + const decoder = name === "stderr" && stderrTail !== undefined ? new TextDecoder() : undefined; + const appendTail = (text: string) => + text.length === 0 || stderrTail === undefined + ? Effect.void + : Ref.update(stderrTail, (current) => (current + text).slice(-4096)); + return stream.pipe( + Stream.runForEach((bytes) => + Effect.gen(function* () { + if (decoder !== undefined) yield* appendTail(decoder.decode(bytes, { stream: true })); + yield* PubSub.publish(logs, { stream: name, bytes }); + }), + ), + Effect.andThen( + decoder === undefined + ? Effect.void + : Effect.sync(() => decoder.decode()).pipe(Effect.flatMap(appendTail)), + ), + Effect.catch((cause) => Effect.logError(cause)), + ); + }; + return Effect.gen(function* () { + yield* Effect.forkIn(drain(process.stdout, "stdout"), scope); + return yield* Effect.forkIn(drain(process.stderr, "stderr"), scope); + }); +}; + +/** Wraps a container process into a `RuntimeSession`; `discard` is included only when requested. */ +export const runtimeSessionFromContainer = ( + process: ContainerProcess, + describeExit: (code: number) => string, + options?: { readonly discard?: boolean }, +): RuntimeSession => ({ + health: Effect.void, + exit: processExit(process.exitCode, describeExit), + stop: process.stop.pipe(Effect.mapError((cause) => mapToServiceError("stop", cause))), + ...(options?.discard === true + ? { + discard: process.discard.pipe(Effect.mapError((cause) => mapToServiceError("stop", cause))), + } + : {}), + remove: process.remove.pipe(Effect.mapError((cause) => mapToServiceError("remove", cause))), +}); diff --git a/packages/stack/src/runtime/Session.unit.test.ts b/packages/stack/src/runtime/Session.unit.test.ts new file mode 100644 index 0000000000..fcf290afef --- /dev/null +++ b/packages/stack/src/runtime/Session.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause } from "effect"; +import { mapToServiceError } from "./Session.ts"; + +describe("mapToServiceError", () => { + it("names an error whose cause carries no message", () => { + const failure = mapToServiceError("stop", new Cause.UnknownError(undefined)); + + expect(failure.message).toBe("UnknownError"); + }); + + it("describes a wrapped error without a message by its cause", () => { + const cause = new Cause.UnknownError(new Error("container is gone")); + const failure = mapToServiceError("stop", cause); + + expect(failure.message).toBe("container is gone"); + }); +}); diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 9abe6fd8d0..fbcde0f5f3 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -37,6 +37,12 @@ import { ensureInternalDatabase, makeDatabaseSessionFromSqlClient, } from "../runtime/PostgresDatabaseSession.ts"; +import { + mapToServiceError, + processExit as sharedProcessExit, + publishProcessLogs, + runtimeSessionFromContainer, +} from "../runtime/Session.ts"; import { ServiceError, ServiceLaunchError, @@ -139,14 +145,7 @@ export interface DatabaseComponent { readonly logs: Stream.Stream; } -const errorFor = (operation: string, cause: unknown): ServiceError => - cause instanceof ServiceError - ? cause - : new ServiceError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +const errorFor = mapToServiceError; const postgresArguments = (config: DatabaseConfig): Array => { const configured = new Set(Object.keys(config.settings ?? {}).map((key) => key.toLowerCase())); @@ -168,13 +167,7 @@ const databaseError = (operation: string, cause: unknown): DatabaseError => cause, }); -/** A descendant outside the process group can keep stderr open after the launcher exits. */ -const stderrTailReady = (drained: Fiber.Fiber) => - Fiber.await(drained).pipe( - Effect.asVoid, - Effect.raceFirst(Effect.sleep("1 second")), - Effect.ignore, - ); +const describePostgresExit = (code: number) => `PostgreSQL exited with code ${code}`; /** Settles a native PostgreSQL exit, waiting briefly after it for the stderr tail to drain. */ export const processExit = ( @@ -184,31 +177,7 @@ export const processExit = ( readonly drained: Fiber.Fiber; }, ): Effect.Effect> => - (stderr === undefined - ? exitCode - : exitCode.pipe(Effect.tap(() => stderrTailReady(stderr.drained))) - ).pipe( - Effect.flatMap((code) => - Number(code) === 0 - ? Effect.void - : (stderr === undefined ? Effect.succeed("") : Ref.get(stderr.tail)).pipe( - Effect.flatMap((text) => { - const detail = text.trim(); - return Effect.fail( - new ServiceError({ - operation: "exit", - message: - detail.length === 0 - ? `PostgreSQL exited with code ${String(code)}` - : `PostgreSQL exited with code ${String(code)}: ${detail}`, - }), - ); - }), - ), - ), - Effect.mapError((cause) => errorFor("exit", cause)), - Effect.exit, - ); + sharedProcessExit(exitCode, describePostgresExit, stderr); const reconcileContainerPassword = Effect.fn("Database.reconcileContainerPassword")( ( @@ -366,53 +335,10 @@ const health = Effect.fn("Database.health")(( ); }); -const publishLogs = Effect.fn("Database.publishLogs")(( - process: { - readonly stdout: Stream.Stream; - readonly stderr: Stream.Stream; - }, - logs: PubSub.PubSub, - scope: Scope.Closeable, - stderrTail?: Ref.Ref, -) => { - const drain = (stream: Stream.Stream, name: DatabaseLog["stream"]) => { - const decoder = name === "stderr" && stderrTail !== undefined ? new TextDecoder() : undefined; - const appendTail = (text: string) => - text.length === 0 || stderrTail === undefined - ? Effect.void - : Ref.update(stderrTail, (current) => (current + text).slice(-4096)); - return stream.pipe( - Stream.runForEach((bytes) => - Effect.gen(function* () { - if (decoder !== undefined) yield* appendTail(decoder.decode(bytes, { stream: true })); - yield* PubSub.publish(logs, { stream: name, bytes }); - }), - ), - Effect.andThen( - decoder === undefined - ? Effect.void - : Effect.sync(() => decoder.decode()).pipe(Effect.flatMap(appendTail)), - ), - Effect.catch((cause) => Effect.logError(cause)), - ); - }; - return Effect.gen(function* () { - yield* Effect.forkIn(drain(process.stdout, "stdout"), scope); - return yield* Effect.forkIn(drain(process.stderr, "stderr"), scope); - }); -}); +const publishLogs = publishProcessLogs; -const runtimeFromContainer = (process: ContainerProcess, discard: boolean): RuntimeSession => ({ - health: Effect.void, - exit: processExit(process.exitCode), - stop: process.stop.pipe(Effect.mapError((cause) => errorFor("stop", cause))), - ...(discard - ? { - discard: process.discard.pipe(Effect.mapError((cause) => errorFor("stop", cause))), - } - : {}), - remove: process.remove.pipe(Effect.mapError((cause) => errorFor("remove", cause))), -}); +const runtimeFromContainer = (process: ContainerProcess, discard: boolean): RuntimeSession => + runtimeSessionFromContainer(process, describePostgresExit, { discard }); const ensureOwnedRoot = Effect.fn("Database.ensureOwnedRoot")(( fs: FileSystem.FileSystem, @@ -592,8 +518,6 @@ export const makeDatabase = ( cacheRoot: options.cacheRoot, runtime: options.runtime, version, - stackId: String(options.stackId), - instanceId: options.instanceId, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), diff --git a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts index d03797aed1..50a83edd3a 100644 --- a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts +++ b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts @@ -1,326 +1,629 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Clock, Crypto, Effect, Exit, FileSystem, Option, Path, Schema, Scope } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { Crypto, Data, Effect, Exit, FileSystem, Path, Ref, Schema, Scope, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveArtifact } from "../Artifacts.ts"; +import { makeContainerRuntime } from "../runtime/Container.ts"; +import { makeDockerDatabaseStorage } from "../storage/DockerDatabaseStorage.ts"; +import { makeDockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; +import { shellQuote } from "../storage/DockerSnapshotBackend.ts"; +import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; import { makeDatabaseSnapshots } from "./DatabaseSnapshot.ts"; const version = "17.6.1.173"; -const setup = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "database-snapshot-" }); - const path = yield* Path.Path; - const cache = path.join(root, "cache"); +class ShellError extends Data.TaggedError("ShellError")<{ readonly message: string }> {} + +type Services = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | Scope.Scope; +type Spawner = ChildProcessSpawner.ChildProcessSpawner["Service"]; - const makeInstance = (name: string) => +const run = (spawner: Spawner, command: string, args: ReadonlyArray) => + Effect.scoped( Effect.gen(function* () { - const instance = path.join(root, name); - yield* fs.makeDirectory(path.join(instance, "data"), { recursive: true }); - yield* fs.writeFileString(path.join(instance, "data", "PG_VERSION"), "17\n"); - yield* fs.writeFileString( - path.join(instance, ".supabase-database-ready.json"), - '{"version":"17.6.1.173","runtime":"native","profile":"supabase"}', + const child = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: "ignore" })); + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(child.stdout)), + Stream.mkString(Stream.decodeText(child.stderr)), + child.exitCode, + ], + { concurrency: "unbounded" }, ); - return instance; - }); + if (Number(code) !== 0) + return yield* new ShellError({ message: `${command} failed: ${stderr.trim()}` }); + return stdout.trim(); + }), + ).pipe( + Effect.mapError((cause) => new ShellError({ message: String(cause) })), + Effect.orDie, + ); + +/** Rewrites one spawned command; other commands pass through. */ +const rewriting = ( + spawner: Spawner, + rewrite: (command: string, args: ReadonlyArray) => ReadonlyArray | undefined, +): Spawner => + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) return spawner.spawn(command); + const replaced = rewrite(command.command, command.args); + if (replaced === undefined) return spawner.spawn(command); + const [executable = "", ...args] = replaced; + return spawner.spawn(ChildProcess.make(executable, args, command.options)); + }); + +interface Overrides { + readonly fs?: FileSystem.FileSystem; + /** Makes the copy tool leave a partial tree and fail. */ + readonly partialCopy?: boolean; + /** Leaves the instance without a data directory or its parent. */ + readonly fresh?: boolean; +} + +interface SnapshotError { + readonly operation: string; +} + +interface Instance { + readonly root: string; + readonly data: string; + readonly entries: string; + readonly stages: string; + readonly restoreStages: string; + readonly saveSnapshot: (key: string) => Effect.Effect; + readonly restoreSnapshot: (key: string) => Effect.Effect; + /** Writes a stopped, ready database whose fixture files hold `value`. */ + readonly seed: (value: string) => Effect.Effect; + readonly destroy: Effect.Effect; +} - return { fs, path, root, cache, makeInstance }; +interface Engine { + readonly runtime: "native" | "docker"; + /** Runs a script where the instance paths above resolve; `tool` prefixes file tools. */ + readonly shell: (script: string) => Effect.Effect; + readonly tool: string; + readonly instance: (name: string, overrides?: Overrides) => Effect.Effect; +} + +const readyMarker = (runtime: string) => + `{"version":"${version}","runtime":"${runtime}","profile":"supabase"}`; + +const native = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const services = yield* Effect.context(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "database-snapshot-contract-" }); + const cacheRoot = `${root}/cache`; + const shell = (script: string) => run(spawner, "/bin/sh", ["-c", script]); + const partialCopy = rewriting(spawner, (command, args) => + command === "cp" + ? [ + "/bin/sh", + "-c", + 'mkdir -p "$1/nested" && printf partial > "$1/nested/fixture"; exit 1', + "sh", + args.at(-1) ?? "", + ] + : undefined, + ); + const instance = (name: string, overrides: Overrides = {}) => + Effect.gen(function* () { + const instanceRoot = `${root}/${name}`; + const data = `${instanceRoot}/data`; + if (overrides.fresh !== true) yield* fs.makeDirectory(data, { recursive: true }); + const store = yield* makeDatabaseSnapshots({ + instanceRoot, + cacheRoot, + runtime: "native", + version, + }).pipe( + Effect.provideService(FileSystem.FileSystem, overrides.fs ?? fs), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + overrides.partialCopy === true ? partialCopy : spawner, + ), + ); + const instance: Instance = { + root: instanceRoot, + data, + entries: `${cacheRoot}/stack-database-snapshots/entries`, + stages: `${cacheRoot}/stack-database-snapshots/stages`, + restoreStages: `${instanceRoot}/.supabase-restore`, + saveSnapshot: store.saveSnapshot, + restoreSnapshot: store.restoreSnapshot, + seed: (value) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${data}/nested`, { recursive: true }); + yield* fs.writeFileString(`${data}/PG_VERSION`, "17\n"); + yield* fs.writeFileString(`${data}/fixture`, value); + yield* fs.writeFileString(`${data}/nested/fixture`, value); + yield* fs.writeFileString( + `${instanceRoot}/.supabase-database-ready.json`, + readyMarker("native"), + ); + }).pipe(Effect.orDie), + destroy: fs.remove(instanceRoot, { recursive: true }).pipe(Effect.orDie), + }; + return instance; + }).pipe(Effect.orDie, Effect.provideContext(services)); + const engine: Engine = { runtime: "native", shell, tool: "", instance }; + return engine; }); -const store = (instance: string, cache: string) => - makeDatabaseSnapshots({ - instanceRoot: instance, - cacheRoot: cache, - runtime: "native", - version, - stackId: "test", - instanceId: "database", +const StorageMarker = Schema.fromJsonString( + Schema.Struct({ + volume: Schema.String, + namespace: Schema.String, + cacheNamespace: Schema.String, + }), +); + +const docker = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const services = yield* Effect.context(); + const scope = yield* Scope.Scope; + const stackId = "snapshot-contract"; + const root = yield* makeDockerDatabaseRoot("database-snapshot-contract-", stackId); + const cacheRoot = path.resolve(root, "../../../cache"); + const image = (yield* resolveArtifact({ service: "database", version }).pipe(Effect.orDie)).image; + const container = yield* makeContainerRuntime({ engine: "docker", root }); + const helpers = yield* makeDockerHelperRegistry( + `snapshot-contract-${yield* crypto.randomUUIDv4}`, + ); + const inspector = yield* Ref.make(undefined); + // The inspector mounts the store volume only after storage has created and labelled it. + const inspect = (volume: string) => + Effect.gen(function* () { + const current = yield* Ref.get(inspector); + if (current !== undefined) return current; + const id = yield* Effect.acquireRelease( + run(spawner, "docker", [ + "run", + "-d", + "--label", + "com.supabase.stack-managed=true", + "--mount", + `type=volume,src=${volume},dst=/store`, + "--entrypoint", + "/bin/sh", + image, + "-c", + "trap : TERM INT; while :; do sleep 3600; done", + ]), + (id) => run(spawner, "docker", ["rm", "-f", id]).pipe(Effect.asVoid), + ).pipe(Scope.provide(scope)); + yield* Ref.set(inspector, id); + return id; + }); + const shell = (script: string) => + Ref.get(inspector).pipe( + Effect.flatMap((id) => + id === undefined + ? Effect.die("Docker store is not created yet") + : run(spawner, "docker", ["exec", id, "/bin/sh", "-c", script]), + ), + ); + const partialCopy = rewriting(spawner, (command, args) => { + const script = args.at(-1) ?? ""; + if (command !== "docker" || args[0] !== "exec" || !script.includes("/usr/local/bin/cp")) + return undefined; + return [ + command, + ...args.slice(0, -1), + script.replace( + /\/usr\/local\/bin\/cp -a --reflink=auto (\S+) (\S+)/u, + (_match, _from: string, to: string) => + `/usr/bin/busybox mkdir -p ${to}/nested; printf partial > ${to}/nested/fixture; exit 1`, + ), + ]; }); + const instance = (name: string, overrides: Overrides = {}) => + Effect.gen(function* () { + const instanceRoot = `${root}/${name}`; + yield* fs.makeDirectory(instanceRoot, { recursive: true }); + const storage = yield* makeDockerDatabaseStorage({ + runtime: "docker", + stackId, + instanceId: name, + instanceRoot, + root, + cacheRoot, + fs: overrides.fs ?? fs, + path, + crypto, + container, + spawner: overrides.partialCopy === true ? partialCopy : spawner, + helpers, + }).pipe(Scope.provide(scope)); + yield* storage.mount(version); + if (overrides.fresh !== true) yield* storage.prepare(version); + const marker = yield* fs + .readFileString(`${instanceRoot}/.supabase-database-storage.json`) + .pipe(Effect.flatMap(Schema.decodeEffect(StorageMarker))); + yield* inspect(marker.volume); + const data = `/store/${marker.namespace}/data`; + const cache = `/store/${marker.cacheNamespace}`; + const instance: Instance = { + root: instanceRoot, + data, + entries: `${cache}/entries`, + stages: `${cache}/stages`, + restoreStages: `${cache}/stages`, + saveSnapshot: (key) => storage.saveSnapshot(version, key), + restoreSnapshot: (key) => storage.restoreSnapshot(version, key), + seed: (value) => + Effect.gen(function* () { + yield* storage.prepare(version); + yield* shell( + `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(`${data}/nested`)}; printf 17 > ${shellQuote(`${data}/PG_VERSION`)}; printf '%s' ${shellQuote(value)} > ${shellQuote(`${data}/fixture`)}; printf '%s' ${shellQuote(value)} > ${shellQuote(`${data}/nested/fixture`)}`, + ); + yield* storage.markInitialized(version); + yield* fs.writeFileString( + `${instanceRoot}/.supabase-database-ready.json`, + readyMarker("docker"), + ); + }).pipe(Effect.orDie), + destroy: storage + .destroyData(version) + .pipe(Effect.andThen(fs.remove(instanceRoot, { recursive: true })), Effect.orDie), + }; + return instance; + }).pipe(Effect.orDie, Effect.provideContext(services)); + const engine: Engine = { runtime: "docker", shell, tool: "/usr/bin/busybox ", instance }; + return engine; +}); -const entries = (fs: FileSystem.FileSystem, path: Path.Path, cache: string) => +const engines = [ + { name: "native", make: native }, + { name: "docker", make: docker }, +]; + +const Descriptor = Schema.fromJsonString(Schema.Struct({ logicalKey: Schema.String })); +const ReadyVersion = Schema.fromJsonString(Schema.Struct({ version: Schema.String })); + +const setup = (make: () => Effect.Effect) => Effect.gen(function* () { - const root = path.join(cache, "stack-database-snapshots", "entries"); - const names = yield* fs.readDirectory(root); - const result: Array<{ readonly name: string; readonly key: string }> = []; - for (const name of names) { - const descriptor = yield* Schema.decodeEffect( - Schema.fromJsonString(Schema.Struct({ logicalKey: Schema.String })), - )(yield* fs.readFileString(path.join(root, name, "descriptor.json"))).pipe( - Effect.orElseSucceed(() => undefined), + const fs = yield* FileSystem.FileSystem; + const engine = yield* make(); + const { shell, tool } = engine; + const fixture = (instance: Instance) => + shell( + `${tool}cat ${shellQuote(`${instance.data}/fixture`)}; echo; ${tool}cat ${shellQuote(`${instance.data}/nested/fixture`)}`, ); - if (descriptor !== undefined) result.push({ name, key: descriptor.logicalKey }); - } - return result; + const contents = (directory: string) => + shell(`if [ -d ${shellQuote(directory)} ]; then ${tool}ls -A ${shellQuote(directory)}; fi`); + const entries = (instance: Instance) => + Effect.gen(function* () { + const listing = yield* shell( + `for entry in ${shellQuote(instance.entries)}/*; do [ -d "$entry" ] || continue; printf '%s ' "$entry"; ${tool}cat "$entry/descriptor.json"; echo; done`, + ); + const result = new Map(); + for (const line of listing.split("\n")) { + const separator = line.indexOf(" "); + const decoded = yield* Schema.decodeEffect(Descriptor)(line.slice(separator + 1)).pipe( + Effect.option, + ); + if (decoded._tag === "Some") + result.set(decoded.value.logicalKey, line.slice(0, separator)); + } + return result; + }); + const entry = (instance: Instance, key: string) => + entries(instance).pipe( + Effect.flatMap((all) => Effect.fromNullishOr(all.get(key))), + Effect.orDie, + ); + return { fs, engine, shell, tool, fixture, contents, entries, entry }; }); -const entryPath = (path: Path.Path, cache: string, name: string) => - path.join(cache, "stack-database-snapshots", "entries", name); - -const live = ( - effect: Effect.Effect< - A, - E, - | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto - | FileSystem.FileSystem - | Path.Path - | Scope.Scope - >, -) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -describe("managed native database snapshots", () => { - it.live("saves, restores, replaces, and isolates source and destination mutations", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const target = yield* makeInstance("target"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "first"); - const sourceStore = yield* store(source, cache); - yield* sourceStore.saveSnapshot("same-key"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "second"); - yield* sourceStore.saveSnapshot("same-key"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "mutated-source"); - yield* fs.remove(source, { recursive: true }); - yield* fs.remove(path.join(target, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(target, "data")); - expect(yield* (yield* store(target, cache)).restoreSnapshot("same-key")).toBe(true); - expect(yield* fs.readFileString(path.join(target, "data", "fixture"))).toBe("second"); - - yield* fs.writeFileString(path.join(target, "data", "fixture"), "destination"); - expect(yield* fs.readFileString(path.join(target, "data", "fixture"))).toBe("destination"); - const secondTarget = yield* makeInstance("second-target"); - yield* fs.remove(path.join(secondTarget, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(secondTarget, "data")); - expect(yield* (yield* store(secondTarget, cache)).restoreSnapshot("same-key")).toBe(true); - expect(yield* fs.readFileString(path.join(secondTarget, "data", "fixture"))).toBe("second"); - }), - ), - ); +const live = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - it.live("returns an absent-key miss without changing an empty target", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const target = yield* makeInstance("target"); - yield* fs.remove(path.join(target, "data", "PG_VERSION")); - const snapshots = yield* store(target, cache); - expect(yield* snapshots.restoreSnapshot("missing")).toBe(false); - expect(yield* fs.readDirectory(path.join(target, "data"))).toEqual([]); - }), - ), - ); +for (const { name, make } of engines) + describe(`${name} database snapshots`, { timeout: 120_000 }, () => { + it.live("restores a saved snapshot that is isolated from later source changes", () => + live( + Effect.gen(function* () { + const { fs, engine, fixture } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + yield* source.seed("mutated"); - it.live("fails a miss on a nonempty target and retains its contents", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const target = yield* makeInstance("target"); - yield* fs.writeFileString(path.join(target, "data", "keep"), "safe"); - const failure = yield* (yield* store(target, cache)) - .restoreSnapshot("missing") - .pipe(Effect.flip); - expect(failure.operation).toBe("restore"); - expect(yield* fs.readFileString(path.join(target, "data", "keep"))).toBe("safe"); - }), - ), - ); + const target = yield* engine.instance("target"); + yield* fs.writeFileString(`${target.root}/.supabase-database-ready.json`, "stale"); + expect(yield* target.restoreSnapshot("key")).toBe(true); - it.live("evicts the oldest entry by touch time and restore updates the LRU time", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const snapshots = yield* store(source, cache); - for (const key of ["one", "two", "three"]) { - yield* fs.writeFileString(path.join(source, "data", "fixture"), key); - yield* snapshots.saveSnapshot(key); - } - const saved = yield* entries(fs, path, cache); - for (const [key, seconds] of [ - ["one", 1_000], - ["two", 2_000], - ["three", 3_000], - ] as const) { - const entry = saved.find((candidate) => candidate.key === key); - if (entry === undefined) throw new Error(`Missing ${key}`); - yield* fs.utimes(entryPath(path, cache, entry.name), seconds, seconds); - } - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - const one = saved.find((entry) => entry.key === "one"); - if (one === undefined) throw new Error("Missing one"); - const beforeRestore = yield* Clock.currentTimeMillis; - expect(yield* snapshots.restoreSnapshot("one")).toBe(true); - const afterRestore = yield* Clock.currentTimeMillis; - const touched = Option.match((yield* fs.stat(entryPath(path, cache, one.name))).mtime, { - onNone: () => undefined, - onSome: (mtime) => mtime.getTime(), - }); - if (touched === undefined) throw new Error("Snapshot touch time is unavailable"); - expect(touched).toBeGreaterThanOrEqual(beforeRestore - 1_000); - expect(touched).toBeLessThanOrEqual(afterRestore + 1_000); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "four"); - yield* snapshots.saveSnapshot("four"); - expect((yield* entries(fs, path, cache)).map((entry) => entry.key).sort()).toEqual([ - "four", - "one", - "three", - ]); - }), - ), - ); + expect(yield* fixture(target)).toBe("saved\nsaved"); + expect( + yield* Schema.decodeEffect(ReadyVersion)( + yield* fs.readFileString(`${target.root}/.supabase-database-ready.json`), + ), + ).toEqual({ version }); + }), + ), + ); - it.live("treats an incompatible descriptor as a full-key miss", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const snapshots = yield* store(source, cache); - yield* snapshots.saveSnapshot("old-version"); - const saved = yield* entries(fs, path, cache); - const entry = saved.find((candidate) => candidate.key === "old-version"); - if (entry === undefined) throw new Error("Missing old-version"); - const descriptorPath = path.join(entryPath(path, cache, entry.name), "descriptor.json"); - const descriptor = yield* fs.readFileString(descriptorPath); - yield* fs.writeFileString(descriptorPath, descriptor.replace(version, "15.14.1.173")); - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - expect(yield* snapshots.restoreSnapshot("old-version")).toBe(false); - expect(yield* fs.readDirectory(path.join(source, "data"))).toEqual([]); - }), - ), - ); + it.live("restores into an instance without a data directory or its parent", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); - it.live("rejects corrupt manifests and data before changing the target", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const snapshots = yield* store(source, cache); - yield* snapshots.saveSnapshot("corrupt-manifest"); - const saved = yield* entries(fs, path, cache); - const manifest = saved.find((candidate) => candidate.key === "corrupt-manifest"); - if (manifest === undefined) throw new Error("Missing corrupt-manifest"); - yield* fs.writeFileString( - path.join(entryPath(path, cache, manifest.name), "descriptor.json"), - "{}", - ); - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - const manifestFailure = yield* snapshots - .restoreSnapshot("corrupt-manifest") - .pipe(Effect.flip); - expect(manifestFailure.operation).toBe("descriptor"); - expect(yield* fs.readDirectory(path.join(source, "data"))).toEqual([]); - - yield* fs.writeFileString(path.join(source, "data", "PG_VERSION"), "17\n"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "invalid"); - yield* snapshots.saveSnapshot("corrupt-data"); - const dataEntry = (yield* entries(fs, path, cache)).find( - (candidate) => candidate.key === "corrupt-data", - ); - if (dataEntry === undefined) throw new Error("Missing corrupt-data"); - yield* fs.writeFileString( - path.join(entryPath(path, cache, dataEntry.name), "data", "PG_VERSION"), - "16\n", - ); - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - const dataFailure = yield* snapshots.restoreSnapshot("corrupt-data").pipe(Effect.flip); - expect(dataFailure.operation).toBe("validate"); - expect(yield* fs.readDirectory(path.join(source, "data"))).toEqual([]); - }), - ), - ); + const target = yield* engine.instance("target", { fresh: true }); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("saved\nsaved"); + }), + ), + ); - it.live("completes concurrent same-key saves with complete generations", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const first = yield* makeInstance("first"); - const second = yield* makeInstance("second"); - for (const [root, value] of [ - [first, "first"], - [second, "second"], - ] as const) { - yield* fs.writeFileString(path.join(root, "data", "generation"), value); - yield* fs.makeDirectory(path.join(root, "data", "nested")); - yield* fs.writeFileString(path.join(root, "data", "nested", "generation"), value); - } - const results = yield* Effect.all( - [ - (yield* store(first, cache)).saveSnapshot("same-key"), - (yield* store(second, cache)).saveSnapshot("same-key"), - ], - { concurrency: "unbounded" }, - ); - expect(results).toHaveLength(2); - const target = yield* makeInstance("target"); - yield* fs.remove(path.join(target, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(target, "data")); - expect(yield* (yield* store(target, cache)).restoreSnapshot("same-key")).toBe(true); - const generation = yield* fs.readFileString(path.join(target, "data", "generation")); - expect(["first", "second"]).toContain(generation); - expect(yield* fs.readFileString(path.join(target, "data", "nested", "generation"))).toBe( - generation, - ); - }), - ), - ); + it.live("replaces an existing key with the newer generation", () => + live( + Effect.gen(function* () { + const { engine, fixture, entries } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("first"); + yield* source.saveSnapshot("key"); + yield* source.seed("second"); + yield* source.saveSnapshot("key"); - it.live("reclaims stale stages before the next operation", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const stageRoot = path.join(cache, "stack-database-snapshots", "stages"); - yield* fs.makeDirectory(path.join(stageRoot, "abandoned"), { recursive: true }); - const staleRestore = path.join(source, ".supabase-restore-abandoned", "data"); - yield* fs.makeDirectory(staleRestore, { recursive: true }); - yield* (yield* store(source, cache)).saveSnapshot("cleanup"); - expect(yield* fs.exists(path.join(stageRoot, "abandoned"))).toBe(false); - expect(yield* fs.exists(path.join(source, ".supabase-restore-abandoned"))).toBe(false); - }), - ), - ); + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("second\nsecond"); + expect([...(yield* entries(source)).keys()]).toEqual(["key"]); + }), + ), + ); - it.live("rolls back a failed marker publication", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const target = yield* makeInstance("target"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "snapshot"); - yield* (yield* store(source, cache)).saveSnapshot("rollback"); - yield* fs.remove(path.join(target, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(target, "data")); - let fail = true; - const failingFs: FileSystem.FileSystem = { - ...fs, - rename: (from, to) => { - if ( - fail && - from.endsWith("/ready.json") && + it.live("misses an absent key and leaves the target empty", () => + live( + Effect.gen(function* () { + const { engine, contents } = yield* setup(make); + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("absent")).toBe(false); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("refuses to restore over existing data", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const target = yield* engine.instance("target"); + yield* target.seed("existing"); + const failure = yield* target.restoreSnapshot("absent").pipe(Effect.flip); + expect(failure.operation).toBe("restore"); + expect(yield* fixture(target)).toBe("existing\nexisting"); + }), + ), + ); + + it.live("refuses to save a running database", () => + live( + Effect.gen(function* () { + const { engine, entries, shell, tool } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("running"); + yield* shell(`${tool}touch ${shellQuote(`${source.data}/postmaster.pid`)}`); + const failure = yield* source.saveSnapshot("key").pipe(Effect.flip); + expect(failure.operation).toBe("data"); + expect((yield* entries(source)).size).toBe(0); + }), + ), + ); + + it.live("keeps the previous generation and no stages when a copy fails midway", () => + live( + Effect.gen(function* () { + const { engine, fixture, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("first"); + yield* source.saveSnapshot("key"); + const failingSource = yield* engine.instance("source", { partialCopy: true }); + yield* failingSource.seed("second"); + expect((yield* failingSource.saveSnapshot("key").pipe(Effect.flip)).operation).toBe( + "copy", + ); + expect(yield* contents(source.stages)).toBe(""); + + const failingTarget = yield* engine.instance("target", { partialCopy: true }); + expect((yield* failingTarget.restoreSnapshot("key").pipe(Effect.flip)).operation).toBe( + "copy", + ); + expect(yield* contents(failingTarget.data)).toBe(""); + expect(yield* contents(failingTarget.restoreStages)).toBe(""); + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("first\nfirst"); + }), + ), + ); + + it.live("recovers a generation left retired by an interrupted save", () => + live( + Effect.gen(function* () { + const { engine, entry, fixture, shell, tool } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + const saved = yield* entry(source, "key"); + const digest = saved.slice(saved.lastIndexOf("/") + 1); + yield* shell( + `${tool}mv ${shellQuote(saved)} ${shellQuote(`${source.stages}/retired-${digest}-abandoned`)}`, + ); + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("saved\nsaved"); + }), + ), + ); + + it.live("keeps the newest entry and the two most recently used others", () => + live( + Effect.gen(function* () { + const { engine, entries } = yield* setup(make); + const source = yield* engine.instance("source"); + for (const key of ["one", "two", "three"]) { + yield* source.seed(key); + yield* source.saveSnapshot(key); + } + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("one")).toBe(true); + yield* source.seed("four"); + yield* source.saveSnapshot("four"); + + expect([...(yield* entries(source)).keys()].sort()).toEqual(["four", "one", "three"]); + }), + ), + ); + + it.live("misses a descriptor for another identity and rejects a corrupt one", () => + live( + Effect.gen(function* () { + const { engine, entry, shell, tool, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + const descriptor = shellQuote(`${yield* entry(source, "key")}/descriptor.json`); + const saved = yield* shell(`${tool}cat ${descriptor}`); + const target = yield* engine.instance("target"); + + yield* shell( + `printf '%s' ${shellQuote(saved.replace(version, "15.14.1.173"))} > ${descriptor}`, + ); + expect(yield* target.restoreSnapshot("key")).toBe(false); + expect(yield* contents(target.data)).toBe(""); + + yield* shell(`printf '{}' > ${descriptor}`); + expect((yield* target.restoreSnapshot("key").pipe(Effect.flip)).operation).toBe( + "descriptor", + ); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("rejects snapshot data for another PostgreSQL major", () => + live( + Effect.gen(function* () { + const { engine, entry, shell, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + yield* shell( + `printf 16 > ${shellQuote(`${yield* entry(source, "key")}/data/PG_VERSION`)}`, + ); + const target = yield* engine.instance("target"); + expect((yield* target.restoreSnapshot("key").pipe(Effect.flip)).operation).toBe( + "validate", + ); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("restores snapshots after the source instance is destroyed", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("survivor"); + yield* source.saveSnapshot("key"); + yield* source.destroy; + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("survivor\nsurvivor"); + }), + ), + ); + + it.live("keeps the previous marker and empties the target when marker publication fails", () => + live( + Effect.gen(function* () { + const { fs, engine, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + const failingFs = FileSystem.FileSystem.of({ + ...fs, + rename: (from, to) => to.endsWith(".supabase-database-ready.json") - ) { - fail = false; - return Effect.die(new Error("injected marker publication failure")); - } - return fs.rename(from, to); - }, - }; - const result = yield* (yield* store(target, cache).pipe( - Effect.provideService(FileSystem.FileSystem, failingFs), - )) - .restoreSnapshot("rollback") - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect( - yield* fs.readFileString(path.join(target, ".supabase-database-ready.json")), - ).toContain(version); - expect(yield* fs.readDirectory(path.join(target, "data"))).toEqual([]); - }), - ), - ); -}); + ? Effect.die("Injected marker publication failure") + : fs.rename(from, to), + }); + const target = yield* engine.instance("target", { fs: failingFs }); + yield* fs.writeFileString(`${target.root}/.supabase-database-ready.json`, "previous"); + + expect(Exit.isFailure(yield* target.restoreSnapshot("key").pipe(Effect.exit))).toBe(true); + expect(yield* fs.readFileString(`${target.root}/.supabase-database-ready.json`)).toBe( + "previous", + ); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("publishes one complete generation from concurrent saves of a key", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const first = yield* engine.instance("first"); + const second = yield* engine.instance("second"); + yield* first.seed("first"); + yield* second.seed("second"); + yield* Effect.all([first.saveSnapshot("key"), second.saveSnapshot("key")], { + concurrency: "unbounded", + }); + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(["first\nfirst", "second\nsecond"]).toContain(yield* fixture(target)); + }), + ), + ); + + it.live("reclaims stale stages before the next operation", () => + live( + Effect.gen(function* () { + const { engine, shell, tool, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* shell(`${tool}mkdir -p ${shellQuote(`${source.stages}/abandoned`)}`); + yield* shell(`${tool}mkdir -p ${shellQuote(`${source.restoreStages}/abandoned`)}`); + yield* source.saveSnapshot("key"); + expect(yield* contents(source.stages)).toBe(""); + expect(yield* contents(source.restoreStages)).toBe(""); + }), + ), + ); + + it.live("refuses to clear a restore-stage root that is a symbolic link", () => + live( + Effect.gen(function* () { + const { engine, shell, tool } = yield* setup(make); + const target = yield* engine.instance("target"); + const victim = `${target.restoreStages}-victim`; + yield* shell( + `set -eu; ${tool}rm -rf ${shellQuote(target.restoreStages)}; ${tool}mkdir -p ${shellQuote(victim)}; printf kept > ${shellQuote(`${victim}/sentinel`)}; ${tool}ln -s ${shellQuote(victim)} ${shellQuote(target.restoreStages)}`, + ); + + const failure = yield* target.restoreSnapshot("absent").pipe(Effect.flip); + expect(failure.operation).toBe("clear"); + expect(yield* shell(`${tool}cat ${shellQuote(`${victim}/sentinel`)}`)).toBe("kept"); + }), + ), + ); + }); diff --git a/packages/stack/src/services/DatabaseSnapshot.ts b/packages/stack/src/services/DatabaseSnapshot.ts index d4a951b8a2..f6be1cdac9 100644 --- a/packages/stack/src/services/DatabaseSnapshot.ts +++ b/packages/stack/src/services/DatabaseSnapshot.ts @@ -13,376 +13,494 @@ import { Schedule, Schema, } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { postgresVersion } from "../Artifacts.ts"; -import { copyDirectory } from "../storage/DirectoryCopy.ts"; +import { failureMessage } from "../internal/failure-message.ts"; +import { copyDirectory, type DirectoryCopyError } from "../storage/DirectoryCopy.ts"; import type { DatabaseRuntime } from "./Database.ts"; -const SnapshotDescriptor = Schema.Struct({ - format: Schema.Literal("supabase-database-snapshot-v1"), - version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), - platform: Schema.String, - arch: Schema.String, - profile: Schema.Literal("supabase"), - logicalKey: Schema.String, - keyDigest: Schema.String, -}); - -export class DatabaseSnapshotError extends Data.TaggedError("DatabaseSnapshotError")<{ - readonly operation: string; - readonly message: string; - readonly cause?: unknown; -}> {} +export class DatabaseSnapshotError extends Schema.TaggedError()( + "DatabaseSnapshotError", + { operation: Schema.String, message: Schema.String, cause: Schema.optionalKey(Schema.Defect()) }, +) {} const errorFor = (operation: string, cause: unknown) => - cause instanceof DatabaseSnapshotError + Schema.is(DatabaseSnapshotError)(cause) ? cause : new DatabaseSnapshotError({ operation, - message: cause instanceof Error ? cause.message : String(cause), + message: failureMessage(cause), cause, }); +const SnapshotStop = Schema.Literals(["nonempty", "miss", "descriptor", "major", "running"]); +/** Names the guard that stopped a snapshot program. */ +type SnapshotStop = typeof SnapshotStop.Type; +/** Decodes a stop name reported by a backend outside this process. */ +export const decodeSnapshotStop = Schema.decodeUnknownEffect(SnapshotStop); + +/** + * One filesystem operation of a snapshot program. Guards stop the program with an outcome + * instead of failing. `Rename` creates the destination's parent and refuses to replace + * anything but an empty directory; an optional rename runs only when its source exists and + * its destination does not. `Recover` moves each `retired--` stage back to + * its entry when that entry is missing. + */ +export type SnapshotStep = Data.TaggedEnum<{ + Ensure: { readonly directory: string }; + Clear: { readonly directory: string }; + Recover: { readonly stages: string; readonly entries: string }; + Expect: { readonly path: string; readonly present: boolean; readonly otherwise: SnapshotStop }; + ExpectEmpty: { readonly directory: string; readonly otherwise: SnapshotStop }; + ExpectText: { readonly file: string; readonly text: string; readonly otherwise: SnapshotStop }; + Copy: { readonly from: string; readonly to: string }; + Adopt: { readonly directory: string }; + Write: { readonly file: string; readonly text: string }; + Rename: { readonly from: string; readonly to: string; readonly optional: boolean }; + Remove: { readonly path: string }; + Touch: { readonly path: string }; + Prune: { readonly directory: string; readonly keep: number; readonly except: string }; +}>; +export const SnapshotStep = Data.taggedEnum(); + +/** Result of a snapshot program; a stopped `ExpectText` carries the file's actual text. */ +export type SnapshotRun = Data.TaggedEnum<{ + Completed: {}; + Stopped: { readonly outcome: SnapshotStop; readonly text: string }; +}>; +export const SnapshotRun = Data.taggedEnum(); + +/** Filesystem namespace in which one engine stores snapshots and database data. */ +export interface SnapshotBackend { + /** Identifies the snapshot store for the host-side lock. */ + readonly lockKey: string; + readonly entries: string; + readonly stages: string; + /** Holds restore stages on the filesystem that holds `data`. */ + readonly restoreStages: string; + readonly data: string; + readonly join: (...parts: ReadonlyArray) => string; + /** Runs every step in order, in one round trip where the backend is remote. */ + readonly run: ( + steps: ReadonlyArray, + ) => Effect.Effect; +} + +/** Snapshot entries kept besides the one just saved. */ +const retainedPrevious = 2; +const format = "supabase-database-snapshot-v1" as const; +const runtimes = Schema.Literals(["native", "docker", "podman"]); +const SnapshotIdentity = Schema.Struct({ + format: Schema.Literal(format), + version: Schema.String, + runtime: runtimes, + platform: Schema.String, + arch: Schema.String, + profile: Schema.Literal("supabase"), + logicalKey: Schema.String, +}); +const SnapshotDescriptor = Schema.Struct({ ...SnapshotIdentity.fields, keyDigest: Schema.String }); const ReadyMarker = Schema.Struct({ version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), + runtime: runtimes, profile: Schema.Literal("supabase"), }); -const markerText = (version: string, runtime: DatabaseRuntime) => - Schema.encodeEffect(Schema.fromJsonString(ReadyMarker))({ - version, - runtime, - profile: "supabase", - }); +const withStoreLock = (lockFile: string, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.try({ + try: () => new DatabaseSync(lockFile), + catch: (cause) => errorFor("lock", cause), + }), + (connection) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => connection.exec("PRAGMA busy_timeout = 0"), + catch: (cause) => errorFor("lock", cause), + }); + yield* Effect.try({ + try: () => connection.exec("BEGIN IMMEDIATE"), + catch: (cause) => errorFor("lock", cause), + }).pipe( + Effect.retry({ + schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "120 seconds" })), + while: (cause) => + Predicate.hasProperty(cause.cause, "errcode") && cause.cause.errcode === 5, + }), + ); + return yield* effect; + }), + (connection) => + Effect.try({ + try: () => connection.close(), + catch: (cause) => errorFor("unlock", cause), + }), + ); -export const makeDatabaseSnapshots = Effect.fn("DatabaseSnapshot.make")(function* (options: { - readonly instanceRoot: string; +/** Saves and restores database data through the snapshot protocol on one backend. */ +export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(function* (options: { + readonly backend: SnapshotBackend; readonly cacheRoot: string; + readonly instanceRoot: string; readonly runtime: DatabaseRuntime; readonly version: string; - readonly stackId: string; - readonly instanceId: string; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const version = postgresVersion(options.version); - const root = path.join(options.cacheRoot, "stack-database-snapshots"); - const entries = path.join(root, "entries"); - const stages = path.join(root, "stages"); - const lockFile = path.join(root, ".lock.sqlite"); - const base = { - format: "supabase-database-snapshot-v1" as const, - version, - runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase" as const, - }; + const { backend, runtime, version } = options; + const { Adopt, Clear, Copy, Ensure, Expect, ExpectEmpty, ExpectText } = SnapshotStep; + const { Prune, Recover, Remove, Rename, Touch, Write } = SnapshotStep; + const major = version.split(".")[0] ?? version; + const markerPath = path.join(options.instanceRoot, ".supabase-database-ready.json"); + const locks = path.join(options.cacheRoot, "stack-database-snapshots", "locks"); const mapError = (operation: string, effect: Effect.Effect) => effect.pipe(Effect.mapError((cause) => errorFor(operation, cause))); - const ensureStore = mapError( - "storage", - fs - .makeDirectory(root, { recursive: true, mode: 0o700 }) - .pipe(Effect.andThen(fs.makeDirectory(entries, { recursive: true, mode: 0o700 }))), - ); - - const withLock = (effect: Effect.Effect) => - Effect.acquireUseRelease( - Effect.try({ - try: () => new DatabaseSync(lockFile), - catch: (cause) => errorFor("lock", cause), - }), - (connection) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => connection.exec("PRAGMA busy_timeout = 0"), - catch: (cause) => errorFor("lock", cause), - }); - yield* Effect.try({ - try: () => connection.exec("BEGIN IMMEDIATE"), - catch: (cause) => errorFor("lock", cause), - }).pipe( - Effect.retry({ - schedule: Schedule.spaced("50 millis").pipe( - Schedule.upTo({ duration: "120 seconds" }), - ), - while: (cause) => - Predicate.hasProperty(cause.cause, "errcode") && cause.cause.errcode === 5, - }), - Effect.mapError((cause) => errorFor("lock", cause)), - ); - return yield* effect; - }), - (connection) => - Effect.try({ - try: () => connection.close(), - catch: (cause) => errorFor("unlock", cause), - }), - ); - - const keyDescriptor = (key: string) => ({ ...base, logicalKey: key }); - const digest = (key: string) => - Schema.encodeEffect( - Schema.fromJsonString( - Schema.Struct({ - format: Schema.Literal("supabase-database-snapshot-v1"), - version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), - platform: Schema.String, - arch: Schema.String, - profile: Schema.Literal("supabase"), - logicalKey: Schema.String, - }), - ), - )(keyDescriptor(key)).pipe( - Effect.flatMap((encoded) => crypto.digest("SHA-256", new TextEncoder().encode(encoded))), - Effect.map((bytes) => - Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - Effect.mapError((cause) => errorFor("key", cause)), - ); - - const copy = (source: string, destination: string) => - copyDirectory(source, destination).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError((cause) => errorFor("copy", cause)), - ); - const readReady = mapError( - "ready", - fs - .readFileString(path.join(options.instanceRoot, ".supabase-database-ready.json")) - .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ReadyMarker)))), + const describe = Effect.fnUntraced( + function* (logicalKey: string) { + const identity = { + format, + version, + runtime, + platform: process.platform, + arch: process.arch, + profile: "supabase" as const, + logicalKey, + }; + const bytes = yield* Schema.encodeEffect(Schema.fromJsonString(SnapshotIdentity))( + identity, + ).pipe( + Effect.flatMap((encoded) => crypto.digest("SHA-256", new TextEncoder().encode(encoded))), + ); + const keyDigest = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + const descriptor = yield* Schema.encodeEffect(Schema.fromJsonString(SnapshotDescriptor))({ + ...identity, + keyDigest, + }); + return { keyDigest, descriptor }; + }, + Effect.mapError((cause) => errorFor("descriptor", cause)), ); - const sourceData = Effect.gen(function* () { - const ready = yield* readReady; - if (ready.version !== version || ready.runtime !== options.runtime) - return yield* errorFor("ready", "Database readiness marker does not match the instance"); - const data = path.join(options.instanceRoot, "data"); - if (!(yield* mapError("data", fs.exists(data)))) - return yield* errorFor("data", "Data is absent"); - if (yield* mapError("data", fs.exists(path.join(data, "postmaster.pid")))) - return yield* errorFor("data", "Database must be stopped before snapshotting"); - const pgVersion = yield* mapError("data", fs.readFileString(path.join(data, "PG_VERSION"))); - if (pgVersion.trim() !== version.split(".")[0]) - return yield* errorFor("data", "Database PostgreSQL major version is incompatible"); - return data; - }); - - const cleanupChildren = (directory: string, predicate: (name: string) => boolean) => - Effect.gen(function* () { - const names = yield* mapError("cleanup", fs.readDirectory(directory)); - for (const name of names) { - if (predicate(name)) - yield* mapError("cleanup", fs.remove(path.join(directory, name), { recursive: true })); - } - }); - - const cleanupStaleStages = Effect.gen(function* () { - yield* cleanupChildren(stages, (name) => name !== "." && name !== ".."); - yield* cleanupChildren(options.instanceRoot, (name) => name.startsWith(".supabase-restore-")); - }); - - const touch = (target: string) => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - yield* mapError("touch", fs.utimes(target, now / 1_000, now / 1_000)); - }); - - const retention = (current: string) => - Effect.gen(function* () { - const names = yield* mapError("retention", fs.readDirectory(entries)); - const values: Array<{ readonly name: string; readonly mtime: number }> = []; - for (const name of names) { - if (name === current) continue; - const info = yield* mapError("retention", fs.stat(path.join(entries, name))); - values.push({ - name, - mtime: Option.match(info.mtime, { - onNone: () => 0, - onSome: (mtime) => mtime.getTime(), - }), - }); - } - values.sort((left, right) => left.mtime - right.mtime || left.name.localeCompare(right.name)); - for (const value of values.slice(0, Math.max(0, values.length - 2))) - yield* mapError( - "retention", - fs.remove(path.join(entries, value.name), { recursive: true }), - ); - }); + const locked = (effect: Effect.Effect) => + mapError("lock", fs.makeDirectory(locks, { recursive: true, mode: 0o700 })).pipe( + Effect.andThen(withStoreLock(path.join(locks, `${backend.lockKey}.sqlite`), effect)), + ); + const token = mapError("stage", crypto.randomUUIDv4); + // Compensation also runs after an interrupt; a failed compensation must not hide the + // failure it follows. + const compensate = + (steps: ReadonlyArray) => + (effect: Effect.Effect) => + effect.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : Effect.uninterruptible( + backend.run(steps).pipe(Effect.catch(Effect.logWarning), Effect.asVoid), + ), + ), + ); + const reclaimStages = [ + Recover({ stages: backend.stages, entries: backend.entries }), + Clear({ directory: backend.stages }), + ...(backend.restoreStages === backend.stages + ? [] + : [Clear({ directory: backend.restoreStages })]), + ]; const saveSnapshot = Effect.fn("DatabaseSnapshot.save")(function* (logicalKey: string) { - const source = yield* sourceData; - const keyDigest = yield* digest(logicalKey); - const descriptor = { ...keyDescriptor(logicalKey), keyDigest }; - const encoded = yield* mapError( - "descriptor", - Schema.encodeEffect(Schema.fromJsonString(SnapshotDescriptor))(descriptor), + const ready = yield* mapError( + "ready", + fs + .readFileString(markerPath) + .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ReadyMarker)))), ); - const target = path.join(entries, keyDigest); - yield* ensureStore; - yield* withLock( + if (ready.version !== version || ready.runtime !== runtime) + return yield* errorFor("ready", "Database readiness marker does not match the instance"); + const { keyDigest, descriptor } = yield* describe(logicalKey); + yield* locked( Effect.gen(function* () { - yield* mapError("storage", fs.makeDirectory(stages, { recursive: true, mode: 0o700 })); - yield* cleanupStaleStages; - const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("stage", cause)), - ); - const stage = path.join(stages, token); - const retired = path.join(stages, "retired-" + token); - yield* mapError("stage", fs.makeDirectory(stage, { recursive: true, mode: 0o700 })); - yield* Effect.acquireUseRelease( - Effect.succeed(stage), - () => - Effect.gen(function* () { - yield* copy(source, path.join(stage, "data")); - yield* mapError( - "descriptor", - fs.writeFileString(path.join(stage, "descriptor.json"), encoded, { mode: 0o600 }), - ); - const hadTarget = yield* mapError("publish", fs.exists(target)); - if (hadTarget) yield* mapError("publish", fs.rename(target, retired)); - const publication = yield* Effect.exit(mapError("publish", fs.rename(stage, target))); - if (Exit.isFailure(publication)) { - if (hadTarget) yield* mapError("rollback", fs.rename(retired, target)); - return yield* Effect.failCause(publication.cause); - } - yield* mapError("publish", fs.remove(retired, { recursive: true, force: true })); - yield* touch(target); - yield* retention(keyDigest); + const id = yield* token; + const stage = backend.join(backend.stages, id); + const retired = backend.join(backend.stages, `retired-${keyDigest}-${id}`); + const target = backend.join(backend.entries, keyDigest); + const result = yield* backend + .run([ + Ensure({ directory: backend.entries }), + ...reclaimStages, + Expect({ + path: backend.join(backend.data, "postmaster.pid"), + present: false, + otherwise: "running", }), - () => mapError("cleanup", fs.remove(stage, { recursive: true, force: true })), - ); + ExpectText({ + file: backend.join(backend.data, "PG_VERSION"), + text: major, + otherwise: "major", + }), + Ensure({ directory: stage }), + Copy({ from: backend.data, to: backend.join(stage, "data") }), + Write({ file: backend.join(stage, "descriptor.json"), text: descriptor }), + Rename({ from: target, to: retired, optional: true }), + Rename({ from: stage, to: target, optional: false }), + Remove({ path: retired }), + Touch({ path: target }), + Prune({ directory: backend.entries, keep: retainedPrevious, except: keyDigest }), + ]) + .pipe( + compensate([ + Rename({ from: retired, to: target, optional: true }), + Remove({ path: stage }), + ]), + ); + if (result._tag === "Stopped") + return yield* result.outcome === "running" + ? errorFor("data", "Database must be stopped before snapshotting") + : errorFor("data", "Database data is missing or has another PostgreSQL major version"); }), ); }); + const publishReadyMarker = Effect.fnUntraced( + function* (id: string) { + const staged = `${markerPath}.${id}`; + const marker = yield* Schema.encodeEffect(Schema.fromJsonString(ReadyMarker))({ + version, + runtime, + profile: "supabase", + }); + yield* fs.writeFileString(staged, marker, { mode: 0o600 }); + yield* fs + .rename(staged, markerPath) + .pipe(Effect.onError(() => fs.remove(staged, { force: true }).pipe(Effect.ignore))); + }, + Effect.mapError((cause) => errorFor("ready", cause)), + ); + const restoreSnapshot = Effect.fn("DatabaseSnapshot.restore")(function* (logicalKey: string) { - const data = path.join(options.instanceRoot, "data"); - const keyDigest = yield* digest(logicalKey); - const target = path.join(entries, keyDigest); - yield* ensureStore; - return yield* withLock( + const { keyDigest, descriptor } = yield* describe(logicalKey); + return yield* locked( Effect.gen(function* () { - yield* mapError("storage", fs.makeDirectory(stages, { recursive: true, mode: 0o700 })); - yield* cleanupStaleStages; - if (yield* mapError("restore", fs.exists(data))) { - if ((yield* mapError("restore", fs.readDirectory(data))).length > 0) - return yield* errorFor("restore", "Restore target data directory must be empty"); - } - if (!(yield* mapError("restore", fs.exists(target)))) return false; - - const descriptor = yield* mapError( - "descriptor", - fs - .readFileString(path.join(target, "descriptor.json")) - .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(SnapshotDescriptor)))), - ); - if (descriptor.keyDigest !== keyDigest || descriptor.logicalKey !== logicalKey) - return yield* errorFor("descriptor", "Snapshot manifest does not match its key"); - if ( - descriptor.version !== version || - descriptor.runtime !== options.runtime || - descriptor.platform !== process.platform || - descriptor.arch !== process.arch - ) - return false; - - const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("stage", cause)), - ); - const stage = path.join(options.instanceRoot, ".supabase-restore-" + token); - const retired = path.join(options.instanceRoot, ".supabase-restore-retired-" + token); - const marker = path.join(options.instanceRoot, ".supabase-database-ready.json"); - const retiredMarker = path.join( - options.instanceRoot, - ".supabase-restore-retired-marker-" + token, - ); - const stagedMarker = path.join(stage, "ready.json"); - yield* mapError("stage", fs.makeDirectory(stage, { recursive: true, mode: 0o700 })); - yield* Effect.acquireUseRelease( - Effect.succeed(stage), - () => - Effect.gen(function* () { - yield* copy(path.join(target, "data"), path.join(stage, "data")); - const stagedData = path.join(stage, "data"); - const stagedVersion = yield* mapError( - "validate", - fs.readFileString(path.join(stagedData, "PG_VERSION")), - ); - if (stagedVersion.trim() !== version.split(".")[0]) - return yield* errorFor( - "validate", - "Snapshot PostgreSQL major version is incompatible", - ); - if (yield* mapError("validate", fs.exists(path.join(stagedData, "postmaster.pid")))) - return yield* errorFor("validate", "Snapshot contains a running database"); - const markerContents = yield* markerText(version, options.runtime).pipe( - Effect.mapError((cause) => errorFor("ready", cause)), - ); - yield* mapError( - "ready", - fs.writeFileString(stagedMarker, markerContents, { mode: 0o600 }), - ); - - // The handoff is short and uninterruptible. If marker publication fails, the old - // complete data tree and marker are restored before the error escapes. - yield* Effect.uninterruptible( - Effect.gen(function* () { - const hadData = yield* mapError("publish", fs.exists(data)); - const hadMarker = yield* mapError("publish", fs.exists(marker)); - if (hadData) yield* mapError("publish", fs.rename(data, retired)); - const dataPublication = yield* Effect.exit( - mapError("publish", fs.rename(path.join(stage, "data"), data)), + const id = yield* token; + const stage = backend.join(backend.restoreStages, id); + const published = backend.join(backend.restoreStages, `${id}.published`); + const entry = backend.join(backend.entries, keyDigest); + // Once the published record exists the target was empty, so rollback may clear it. + const rollback = [ + Remove({ path: stage }), + Expect({ path: published, present: true, otherwise: "miss" }), + Clear({ directory: backend.data }), + ]; + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const result = yield* restore( + backend.run([ + Ensure({ directory: backend.entries }), + ...reclaimStages, + ExpectEmpty({ directory: backend.data, otherwise: "nonempty" }), + Expect({ path: entry, present: true, otherwise: "miss" }), + ExpectText({ + file: backend.join(entry, "descriptor.json"), + text: descriptor, + otherwise: "descriptor", + }), + ExpectText({ + file: backend.join(entry, "data", "PG_VERSION"), + text: major, + otherwise: "major", + }), + Expect({ + path: backend.join(entry, "data", "postmaster.pid"), + present: false, + otherwise: "running", + }), + Copy({ from: backend.join(entry, "data"), to: stage }), + Adopt({ directory: stage }), + Touch({ path: entry }), + Write({ file: published, text: id }), + Rename({ from: stage, to: backend.data, optional: false }), + ]), + ); + if (result._tag === "Stopped") { + switch (result.outcome) { + case "miss": + return false; + case "descriptor": + // A well-formed descriptor for another identity is a miss; anything else is corrupt. + return yield* Schema.decodeEffect(Schema.fromJsonString(SnapshotDescriptor))( + result.text, + ).pipe( + Effect.as(false), + Effect.mapError((cause) => errorFor("descriptor", cause)), ); - if (Exit.isFailure(dataPublication)) { - if (hadData) yield* mapError("rollback", fs.rename(retired, data)); - return yield* Effect.failCause(dataPublication.cause); - } - if (hadMarker) { - const markerRetirement = yield* Effect.exit( - mapError("publish", fs.rename(marker, retiredMarker)), - ); - if (Exit.isFailure(markerRetirement)) { - yield* mapError( - "rollback", - fs.remove(data, { recursive: true, force: true }), - ); - if (hadData) yield* mapError("rollback", fs.rename(retired, data)); - return yield* Effect.failCause(markerRetirement.cause); - } - } - const markerPublication = yield* Effect.exit( - mapError("ready", fs.rename(stagedMarker, marker)), + case "nonempty": + return yield* errorFor("restore", "Restore target data directory must be empty"); + case "major": + return yield* errorFor( + "validate", + "Snapshot PostgreSQL major version is incompatible", ); - if (Exit.isFailure(markerPublication)) { - if (hadMarker) yield* mapError("rollback", fs.rename(retiredMarker, marker)); - yield* mapError("rollback", fs.remove(data, { recursive: true, force: true })); - if (hadData) yield* mapError("rollback", fs.rename(retired, data)); - return yield* Effect.failCause(markerPublication.cause); - } - yield* mapError("publish", fs.remove(retired, { recursive: true, force: true })); - if (hadMarker) - yield* mapError("publish", fs.remove(retiredMarker, { force: true })); - }), - ); - yield* touch(target); - }), - () => mapError("cleanup", fs.remove(stage, { recursive: true, force: true })), - ); - return true; + case "running": + return yield* errorFor("validate", "Snapshot contains a running database"); + } + } + yield* publishReadyMarker(id); + return true; + }), + ).pipe(compensate(rollback)); }), ); }); return { saveSnapshot, restoreSnapshot }; }); + +/** Interprets snapshot programs directly on the host filesystem. */ +const makeNativeSnapshotBackend = Effect.fnUntraced(function* ( + instanceRoot: string, + cacheRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = path.join(cacheRoot, "stack-database-snapshots"); + const exists = (target: string) => fs.exists(target); + const isEmptyDirectory = (target: string) => + fs.readDirectory(target).pipe(Effect.map((names) => names.length === 0)); + const readText = (file: string) => + fs.readFileString(file).pipe( + Effect.map((text) => text.replace(/\n+$/u, "")), + Effect.orElseSucceed(() => undefined), + ); + const proceed = Option.none(); + const stop = (outcome: SnapshotStop, text = "") => + Option.some(SnapshotRun.Stopped({ outcome, text })); + const step = ( + current: SnapshotStep, + ): Effect.Effect< + Option.Option, + PlatformError | DirectoryCopyError | DatabaseSnapshotError + > => + SnapshotStep.$match(current, { + Ensure: ({ directory }) => + fs.makeDirectory(directory, { recursive: true, mode: 0o700 }).pipe(Effect.as(proceed)), + Clear: ({ directory }) => + Effect.gen(function* () { + if (yield* fs.readLink(directory).pipe(Effect.isSuccess)) + return yield* errorFor("clear", `${directory} is a symbolic link`); + yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); + for (const name of yield* fs.readDirectory(directory)) + yield* fs.remove(path.join(directory, name), { recursive: true, force: true }); + return proceed; + }), + Recover: ({ stages, entries }) => + Effect.gen(function* () { + if (!(yield* exists(stages))) return proceed; + for (const name of yield* fs.readDirectory(stages)) { + const digest = /^retired-([0-9a-f]+)-/u.exec(name)?.[1]; + if (digest === undefined || (yield* exists(path.join(entries, digest)))) continue; + yield* fs.makeDirectory(entries, { recursive: true, mode: 0o700 }); + yield* fs.rename(path.join(stages, name), path.join(entries, digest)); + } + return proceed; + }), + Expect: ({ path: target, present, otherwise }) => + exists(target).pipe(Effect.map((found) => (found === present ? proceed : stop(otherwise)))), + ExpectEmpty: ({ directory, otherwise }) => + Effect.gen(function* () { + if (!(yield* exists(directory)) || (yield* isEmptyDirectory(directory))) return proceed; + return stop(otherwise); + }), + ExpectText: ({ file, text, otherwise }) => + readText(file).pipe( + Effect.map((actual) => (actual === text ? proceed : stop(otherwise, actual ?? ""))), + ), + Copy: ({ from, to }) => + copyDirectory(from, to).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.as(proceed), + ), + // Native data already belongs to the user that runs the database. + Adopt: () => Effect.succeedNone, + Write: ({ file, text }) => + fs.writeFileString(file, text, { mode: 0o600 }).pipe(Effect.as(proceed)), + Rename: ({ from, to, optional }) => + Effect.gen(function* () { + const destination = yield* exists(to); + if (optional && (destination || !(yield* exists(from)))) return proceed; + if (destination) { + if (!(yield* isEmptyDirectory(to))) + return yield* errorFor("rename", `${to} already exists`); + yield* fs.remove(to, { recursive: true }); + } + yield* fs.makeDirectory(path.dirname(to), { recursive: true, mode: 0o700 }); + yield* fs.rename(from, to); + return proceed; + }), + Remove: ({ path: target }) => + fs.remove(target, { recursive: true, force: true }).pipe(Effect.as(proceed)), + Touch: ({ path: target }) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => fs.utimes(target, now / 1_000, now / 1_000)), + Effect.as(proceed), + ), + Prune: ({ directory, keep, except }) => + Effect.gen(function* () { + const entries: Array<{ readonly name: string; readonly mtime: number }> = []; + for (const name of yield* fs.readDirectory(directory)) { + if (name === except) continue; + const info = yield* fs.stat(path.join(directory, name)); + const mtime = Option.match(info.mtime, { + onNone: () => 0, + onSome: (date) => date.getTime(), + }); + entries.push({ name, mtime }); + } + entries.sort( + (left, right) => right.mtime - left.mtime || left.name.localeCompare(right.name), + ); + for (const entry of entries.slice(keep)) + yield* fs.remove(path.join(directory, entry.name), { recursive: true, force: true }); + return proceed; + }), + }); + const backend: SnapshotBackend = { + lockKey: "native", + entries: path.join(root, "entries"), + stages: path.join(root, "stages"), + restoreStages: path.join(instanceRoot, ".supabase-restore"), + data: path.join(instanceRoot, "data"), + join: (...parts) => path.join(...parts), + run: Effect.fnUntraced(function* (steps) { + for (const current of steps) { + const stopped = yield* step(current).pipe( + Effect.mapError((cause) => errorFor(current._tag.toLowerCase(), cause)), + ); + if (Option.isSome(stopped)) return stopped.value; + } + return SnapshotRun.Completed(); + }), + }; + return backend; +}); + +/** Snapshots a native database instance into the host cache. */ +export const makeDatabaseSnapshots = Effect.fn("DatabaseSnapshot.make")(function* (options: { + readonly instanceRoot: string; + readonly cacheRoot: string; + readonly runtime: DatabaseRuntime; + readonly version: string; +}) { + return yield* makeSnapshotStore({ + backend: yield* makeNativeSnapshotBackend(options.instanceRoot, options.cacheRoot), + cacheRoot: options.cacheRoot, + instanceRoot: options.instanceRoot, + runtime: options.runtime, + version: postgresVersion(options.version), + }); +}); diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 58c5424ab7..9d1bf18e04 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -34,6 +34,12 @@ import { type NativeProcessError, spawnNativeProcess, } from "../runtime/NativeProcess.ts"; +import { + mapToServiceError, + processExit as sharedProcessExit, + publishProcessLogs, + runtimeSessionFromContainer, +} from "../runtime/Session.ts"; import { ServiceError, ServiceLaunchError, type RuntimeSession } from "../Service.ts"; import { type CatalogLog, @@ -148,18 +154,9 @@ export interface ProcessDependencies { readonly container: ContainerRuntime | undefined; } -const serviceError = (operation: string, cause: unknown): ServiceError => - cause instanceof ServiceError - ? cause - : new ServiceError({ - operation, - message: Cause.isTimeoutError(cause) - ? `Service ${operation} timed out` - : cause instanceof Error - ? cause.message - : String(cause), - cause, - }); +const serviceError = mapToServiceError; + +const describeProcessExit = (code: number) => `Process exited with ${code}`; const catalogError = (operation: string, message: string, service?: ServiceKind, cause?: unknown) => new CatalogError({ @@ -171,25 +168,10 @@ const catalogError = (operation: string, message: string, service?: ServiceKind, const processExit = ( exitCode: Effect.Effect, -): Effect.Effect> => - exitCode.pipe( - Effect.flatMap((code) => - Number(code) === 0 - ? Effect.void - : Effect.fail( - new ServiceError({ operation: "exit", message: `Process exited with ${code}` }), - ), - ), - Effect.mapError((cause) => serviceError("exit", cause)), - Effect.exit, - ); +): Effect.Effect> => sharedProcessExit(exitCode, describeProcessExit); -const runtimeFromContainer = (process: ContainerProcess): RuntimeSession => ({ - health: Effect.void, - exit: processExit(process.exitCode), - stop: process.stop.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: process.remove.pipe(Effect.mapError((cause) => serviceError("remove", cause))), -}); +const runtimeFromContainer = (process: ContainerProcess): RuntimeSession => + runtimeSessionFromContainer(process, describeProcessExit); const runtimeFromNative = (process: NativeProcess): RuntimeSession => ({ health: Effect.void, @@ -242,29 +224,6 @@ const reserveNativePort = Effect.fn("ProcessRecipe.reserveNativePort")( ), ); -const publishLogs = Effect.fn("ProcessRecipe.publishLogs")(( - process: { - readonly stdout: Stream.Stream; - readonly stderr: Stream.Stream; - }, - logs: PubSub.PubSub, - scope: Scope.Closeable, -): Effect.Effect => { - const drain = (stream: Stream.Stream, name: CatalogLog["stream"]) => - stream.pipe( - Stream.runForEach((bytes) => PubSub.publish(logs, { stream: name, bytes })), - Effect.catch((cause) => Effect.logError(cause)), - Effect.asVoid, - ); - return Effect.all( - [ - Effect.forkIn(drain(process.stdout, "stdout"), scope), - Effect.forkIn(drain(process.stderr, "stderr"), scope), - ], - { concurrency: "unbounded", discard: true }, - ); -}); - const startupTimeoutSeconds = 60; const nativeLaunchAttempts = 3; const probeTimeout = Duration.seconds(10); @@ -852,7 +811,7 @@ export const makeProcessRecipe = selected.set(name, { kind: "tcp", host: "127.0.0.1", port: published }); } yield* Ref.set(endpoints, selected); - yield* publishLogs(launched, logs, context.scope); + yield* publishProcessLogs(launched, logs, context.scope); const runtime = runtimeFromContainer(launched); const ready = selected.get("http"); const noReadinessEndpoint = Effect.fail( diff --git a/packages/stack/src/storage/DirectoryCopy.integration.test.ts b/packages/stack/src/storage/DirectoryCopy.integration.test.ts index 31e9dbef3c..39321e98c2 100644 --- a/packages/stack/src/storage/DirectoryCopy.integration.test.ts +++ b/packages/stack/src/storage/DirectoryCopy.integration.test.ts @@ -192,26 +192,27 @@ describe("copyDirectory", () => { ), ); - it.live("removes a failed host copy and copies the tree itself", () => + it.live("removes a failed host copy and reports the failure", () => run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "directory-copy-fallback-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "directory-copy-failed-" }); const source = path.join(root, "source"); const destination = path.join(root, "destination"); yield* fs.makeDirectory(source); yield* fs.writeFileString(path.join(source, "file.txt"), "file\n"); - yield* copyDirectory(source, destination).pipe( + const failure = yield* copyDirectory(source, destination).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, failedHostCopy(fs, path, destination), ), + Effect.flip, ); - expect(yield* fs.readFileString(path.join(destination, "file.txt"))).toBe("file\n"); - expect(yield* fs.exists(path.join(destination, "nested"))).toBe(false); + expect(failure.operation).toBe("copy"); + expect(yield* fs.exists(destination)).toBe(false); }), ), ); diff --git a/packages/stack/src/storage/DirectoryCopy.ts b/packages/stack/src/storage/DirectoryCopy.ts index 8b84843c4b..2c976c1619 100644 --- a/packages/stack/src/storage/DirectoryCopy.ts +++ b/packages/stack/src/storage/DirectoryCopy.ts @@ -1,7 +1,5 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no lstat or clone-copy operation. -import { copyFile as nativeCopyFile, lstat, readdir } from "node:fs/promises"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no clone-copy flags. -import { constants as fsConstants } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no lstat or typed directory listing. +import { lstat, readdir } from "node:fs/promises"; import { Cause, Effect, FileSystem, Option, Path, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -24,26 +22,12 @@ type NativeStats = Awaited>; const errorFor = (operation: string, source: string, destination: string, cause: unknown) => new DirectoryCopyError({ operation, source, destination, cause }); -const isCloneUnsupported = (cause: unknown): boolean => { - if (Schema.is(NativeFileError)(cause)) return isCloneUnsupported(cause.cause); - if (typeof cause !== "object" || cause === null || !("code" in cause)) return false; - const code = cause.code; - return ( - code === "ENOTSUP" || - code === "EOPNOTSUPP" || - code === "ENOSYS" || - code === "EXDEV" || - code === "EINVAL" - ); -}; - const isNotFound = (cause: unknown): boolean => Schema.is(NativeFileError)(cause) ? isNotFound(cause.cause) : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"; -// FileSystem does not expose lstat or clone flags, so these two operations stay at the -// native boundary while the traversal and directory operations use the Effect service. +// FileSystem does not expose lstat, which link detection needs. const nativeLstat = (target: string): Effect.Effect => Effect.uninterruptible( Effect.tryPromise({ @@ -52,19 +36,6 @@ const nativeLstat = (target: string): Effect.Effect => - Effect.uninterruptible( - Effect.tryPromise({ - try: () => - nativeCopyFile(source, destination, clone ? fsConstants.COPYFILE_FICLONE_FORCE : 0), - catch: (cause) => new NativeFileError({ cause }), - }), - ); - const inspect = ( source: string, destination: string, @@ -73,93 +44,7 @@ const inspect = ( Effect.mapError((cause) => errorFor("lstat", source, destination, cause)), ); -const copyFile = (source: string, destination: string): Effect.Effect => { - const regular = nativeCopy(source, destination, false).pipe( - Effect.mapError((cause) => errorFor("copyFile", source, destination, cause)), - ); - if (process.platform === "win32") return regular; - return nativeCopy(source, destination, true).pipe( - Effect.catch((cause: NativeFileError) => - isCloneUnsupported(cause) - ? regular - : Effect.fail(errorFor("copyFile", source, destination, cause)), - ), - ); -}; - -const validateTree = ( - source: string, - destination: string, - fs: FileSystem.FileSystem, - path: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - const stats = yield* inspect(source, destination); - if (stats.isSymbolicLink()) - return yield* errorFor("validate", source, destination, "symbolic link"); - if (!stats.isDirectory()) - return yield* errorFor("validate", source, destination, "not a directory"); - const entries = yield* fs - .readDirectory(source) - .pipe(Effect.mapError((cause) => errorFor("readDirectory", source, destination, cause))); - for (const entry of entries) { - const childSource = path.join(source, entry); - const childDestination = path.join(destination, entry); - const childStats = yield* inspect(childSource, childDestination); - if (childStats.isSymbolicLink()) - return yield* errorFor("validate", childSource, childDestination, "symbolic link"); - if (childStats.isDirectory()) yield* validateTree(childSource, childDestination, fs, path); - else if (!childStats.isFile()) - return yield* errorFor("validate", childSource, childDestination, "special file"); - } - }); - -const copyTree = ( - source: string, - destination: string, - fs: FileSystem.FileSystem, - path: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - const sourceStats = yield* inspect(source, destination); - if (sourceStats.isSymbolicLink()) - return yield* errorFor("validate", source, destination, "symbolic link"); - if (sourceStats.isDirectory() === false) - return yield* errorFor("validate", source, destination, "not a directory"); - - yield* fs - .makeDirectory(destination, { - mode: (Number(sourceStats.mode) | 0o700) & 0o7777, - }) - .pipe(Effect.mapError((cause) => errorFor("makeDirectory", source, destination, cause))); - - const entries = yield* fs - .readDirectory(source) - .pipe(Effect.mapError((cause) => errorFor("readDirectory", source, destination, cause))); - for (const entry of entries) { - const childSource = path.join(source, entry); - const childDestination = path.join(destination, entry); - const childStats = yield* inspect(childSource, childDestination); - if (childStats.isSymbolicLink()) - return yield* errorFor("validate", childSource, childDestination, "symbolic link"); - if (childStats.isDirectory()) { - yield* copyTree(childSource, childDestination, fs, path); - } else if (childStats.isFile()) { - yield* copyFile(childSource, childDestination); - yield* fs - .chmod(childDestination, Number(childStats.mode) & 0o7777) - .pipe( - Effect.mapError((cause) => errorFor("chmod", childSource, childDestination, cause)), - ); - } else { - return yield* errorFor("validate", childSource, childDestination, "special file"); - } - } - yield* fs - .chmod(destination, Number(sourceStats.mode) & 0o7777) - .pipe(Effect.mapError((cause) => errorFor("chmod", source, destination, cause))); - }); - +/** Copies a plain directory tree with one host copy process; links and special files fail. */ export const copyDirectory = Effect.fn("DirectoryCopy.copyDirectory")(function* ( source: string, destination: string, @@ -175,39 +60,45 @@ export const copyDirectory = Effect.fn("DirectoryCopy.copyDirectory")(function* onSuccess: () => Effect.fail(errorFor("validate", source, destination, "destination exists")), }), ); - // cp and robocopy keep or follow links. Only a plain directory tree can use them. - const copies = hostCopies(source, destination); - if (copies.length > 0 && (yield* directoryTreeIsCopyable(source, destination, path))) { - for (const copy of copies) { - const copied = yield* runExec( - copy.command, - copy.args, - source, - destination, - "copy", - copy.acceptStatus, - ).pipe( - Effect.asVoid, - // matchCauseEffect does not observe an external interrupt. - Effect.onInterrupt(() => removePartial(destination, fs, path)), - Effect.matchCauseEffect({ - onSuccess: () => Effect.succeed("copied" as const), - onFailure: (cause) => - removePartial(destination, fs, path).pipe( - Effect.flatMap(() => { - if (Cause.hasInterrupts(cause)) return Effect.failCause(cause); - const outcome = isUsageFailure(cause) ? "usage" : "failed"; - return Effect.succeed(outcome); + const stats = yield* inspect(source, destination); + if (stats.isSymbolicLink() || !stats.isDirectory()) + return yield* errorFor("validate", source, destination, "not a plain directory"); + // cp and robocopy keep or follow links, so only a plain tree reaches them. + const unsupported = yield* process.platform === "win32" + ? scanUnsupported(source, destination, path) + : findUnsupportedEntry(source, destination); + if (unsupported) return yield* errorFor("validate", source, destination, "link or special file"); + let failure: DirectoryCopyError | undefined; + for (const copy of hostCopies(source, destination)) { + const copied = yield* runExec( + copy.command, + copy.args, + source, + destination, + "copy", + copy.acceptStatus, + ).pipe( + Effect.asVoid, + // matchCauseEffect does not observe an external interrupt. + Effect.onInterrupt(() => removePartial(destination, fs, path)), + Effect.matchCauseEffect({ + onSuccess: () => Effect.succeedNone, + onFailure: (cause) => + removePartial(destination, fs, path).pipe( + Effect.flatMap(() => + Option.match(Cause.findErrorOption(cause), { + onNone: () => Effect.failCause(cause), + onSome: (error) => Effect.succeedSome(error), }), ), - }), - ); - if (copied === "copied") return; - if (copied === "failed") break; - } + ), + }), + ); + if (Option.isNone(copied)) return; + failure = copied.value; + if (!isUsageError(failure.cause)) break; } - yield* validateTree(source, destination, fs, path); - return yield* copyTree(source, destination, fs, path); + return yield* failure ?? errorFor("copy", source, destination, "no host copy tool"); }); const runExec = ( @@ -241,27 +132,6 @@ const runExec = ( }), ); -const directoryTreeIsCopyable = ( - source: string, - destination: string, - path: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - const stats = yield* inspect(source, destination); - if (!stats.isDirectory() || stats.isSymbolicLink()) return false; - return yield* ( - process.platform === "win32" - ? scanUnsupported(source, destination, path) - : findUnsupportedEntry(source, destination) - ).pipe( - Effect.matchCauseEffect({ - onSuccess: (found) => Effect.succeed(!found), - onFailure: (cause) => - Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(false), - }), - ); - }); - const findUnsupportedEntry = ( source: string, destination: string, @@ -390,12 +260,6 @@ const isUsageError = (cause: unknown): boolean => { ); }; -const isUsageFailure = (cause: Cause.Cause): boolean => - Option.match(Cause.findErrorOption(cause), { - onNone: () => false, - onSome: (error) => isUsageError(error.cause), - }); - // One process copies the tree. macOS clones, and Linux reflinks when the filesystem can. const hostCopies = (source: string, destination: string): ReadonlyArray => { switch (process.platform) { diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index ab3e24dcd2..402cfd6a06 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -1055,8 +1055,6 @@ describe("Docker database storage", { timeout: 120_000 }, () => { cacheRoot, runtime: "native", version: "17", - stackId: "native-interoperability", - instanceId: "database", }); yield* nativeSnapshots.saveSnapshot("native"); yield* fs.remove(path.join(nativeRoot, "data"), { recursive: true }); diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 51b81510e6..3b016a22b8 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -14,9 +14,12 @@ import { import { ChildProcess } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; +import { failureMessage } from "../internal/failure-message.ts"; import type { ContainerRuntime } from "../runtime/Container.ts"; import type { DatabaseRuntime } from "../services/Database.ts"; +import { DatabaseSnapshotError, makeSnapshotStore } from "../services/DatabaseSnapshot.ts"; import type { DockerHelperRegistry } from "./DockerHelperRegistry.ts"; +import { makeDockerSnapshotBackend, shellQuote } from "./DockerSnapshotBackend.ts"; const Marker = Schema.Struct({ backend: Schema.Literals(["docker", "host"]), @@ -27,29 +30,6 @@ const Marker = Schema.Struct({ initialized: Schema.Boolean, }); type Marker = Schema.Schema.Type; -const SnapshotIdentity = Schema.Struct({ - format: Schema.String, - version: Schema.String, - runtime: Schema.String, - platform: Schema.String, - arch: Schema.String, - profile: Schema.String, - key: Schema.String, -}); -const SnapshotDescriptor = Schema.Struct({ - format: Schema.String, - version: Schema.String, - runtime: Schema.String, - platform: Schema.String, - arch: Schema.String, - profile: Schema.String, - keyDigest: Schema.String, -}); -const ReadyMarker = Schema.Struct({ - version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), - profile: Schema.Literal("supabase"), -}); const DaemonIdentity = Schema.Struct({ daemonId: Schema.String, clientMajor: Schema.Finite, @@ -98,22 +78,21 @@ export interface DockerDatabaseStorage { ) => Effect.Effect; } +// Snapshot failures keep the protocol's operation so both engines report the same step. const errorFor = (operation: string, cause: unknown) => Schema.is(DockerDatabaseStorageError)(cause) ? cause - : new DockerDatabaseStorageError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); - -const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; - -// The helper image is built externally and must ship busybox and a reflink-capable `cp`; -// preflighting fails fast naming the missing binary instead of a confusing error deep in -// the lock or copy step. -const withSnapshotLock = (root: string, command: string): string => - `set -eu; for helper_binary in /usr/bin/busybox /usr/local/bin/cp; do command -v "$helper_binary" >/dev/null 2>&1 || { echo "Database snapshot helper image is missing required binary: $helper_binary" >&2; exit 97; }; done; /usr/bin/busybox mkdir -p ${shellQuote(root)}; exec 9>${shellQuote(`${root}/.lock`)}; attempt=0; until lock_error=$(/usr/bin/busybox flock -n 9 2>&1); do if [ -n "$lock_error" ]; then echo "$lock_error" >&2; exit 1; fi; if [ "$attempt" -ge 120 ]; then echo 'Timed out waiting for database snapshot lock' >&2; exit 1; fi; attempt=$((attempt + 1)); /usr/bin/busybox sleep 1; done; /usr/bin/sh -eu -c ${shellQuote(command)}`; + : Schema.is(DatabaseSnapshotError)(cause) + ? new DockerDatabaseStorageError({ + operation: cause.operation, + message: cause.message, + cause, + }) + : new DockerDatabaseStorageError({ + operation, + message: failureMessage(cause), + cause, + }); const parseMajor = (version: string): number | undefined => { const major = Number(version.trim().split(".")[0]); @@ -152,8 +131,6 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.mapError((cause) => errorFor("identity", cause)), ); const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); - const encodeIdentity = Schema.encodeEffect(Schema.fromJsonString(SnapshotIdentity)); - const encodeDescriptor = Schema.encodeEffect(Schema.fromJsonString(SnapshotDescriptor)); const validateMarker = (marker: Marker) => Effect.gen(function* () { if ( @@ -793,7 +770,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.gen(function* () { if (!(yield* hasUnmarkedData)) return; yield* runHelper( - "set -eu; rm -rf /instance/data /instance/.supabase-restore-*", + "set -eu; rm -rf /instance/data /instance/.supabase-restore", [{ source: options.instanceRoot, target: "/instance", readOnly: false }], version, true, @@ -806,27 +783,6 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") options.fs.writeFileString(markerPath, encoded, { mode: 0o600 }), ), ); - const publishReadyMarker = (version: string) => - Effect.scoped( - Effect.gen(function* () { - const stage = yield* options.fs.makeTempDirectoryScoped({ - directory: options.instanceRoot, - prefix: ".ready-", - }); - const ready = yield* Schema.encodeEffect(Schema.fromJsonString(ReadyMarker))({ - version, - runtime: options.runtime, - profile: "supabase", - }); - const staged = options.path.join(stage, "marker"); - const destination = options.path.join( - options.instanceRoot, - ".supabase-database-ready.json", - ); - yield* options.fs.writeFileString(staged, ready, { mode: 0o600 }); - yield* options.fs.rename(staged, destination); - }), - ); const setup = Effect.fn("DockerDatabaseStorage.prepare")((version: string) => Effect.gen(function* () { yield* selected; @@ -924,7 +880,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( - `set -eu; rm -rf /instance/data /instance/.supabase-restore-*; mkdir -p /instance/data; chown 100:101 /instance/data`, + `set -eu; rm -rf /instance/data /instance/.supabase-restore; mkdir -p /instance/data; chown 100:101 /instance/data`, snapshotPaths(marker).mounts, version, true, @@ -951,7 +907,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( - `set -eu; rm -rf /instance/data /instance/.supabase-restore-*`, + `set -eu; rm -rf /instance/data /instance/.supabase-restore`, snapshotPaths(marker).mounts, version, true, @@ -1001,19 +957,37 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") { source: options.cacheRoot, target: "/cache", readOnly: false as const }, ], }; - const descriptorDigest = (version: string, key: string) => - encodeIdentity({ - format: "supabase-database-snapshot-v1", - version, + const snapshots = (store: Marker, version: string) => { + const paths = snapshotPaths(store); + const host = store.backend === "host"; + return makeSnapshotStore({ + backend: makeDockerSnapshotBackend({ + lockKey: host + ? `host-${store.cacheNamespace}` + : `${store.volume ?? "volume"}-${store.cacheNamespace}`, + root: paths.root, + data: paths.source, + restoreStages: host ? "/instance/.supabase-restore" : `${paths.root}/stages`, + // Host-backed snapshots stay removable by the host user; restored data belongs + // to the database user. + ...(host + ? { + adoptOwner: "100:101", + epilogue: `owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${shellQuote(paths.root)}`, + } + : {}), + exec: (script) => runHelper(script, paths.mounts, version), + }), + cacheRoot: options.cacheRoot, + instanceRoot: options.instanceRoot, runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase", - key, + version, }).pipe( - Effect.mapError((cause) => errorFor("snapshot", cause)), - Effect.flatMap(hash), + Effect.provideService(FileSystem.FileSystem, options.fs), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), ); + }; const saveSnapshot = Effect.fn("DockerDatabaseStorage.saveSnapshot")( (version: string, key: string) => Effect.gen(function* () { @@ -1021,51 +995,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const store = yield* getMarker; if (!store.initialized) return yield* errorFor("snapshot", "Database is not initialized"); - const readyPath = options.path.join( - options.instanceRoot, - ".supabase-database-ready.json", - ); - if ( - !(yield* options.fs - .exists(readyPath) - .pipe(Effect.mapError((cause) => errorFor("snapshot", cause)))) - ) - return yield* errorFor("snapshot", "Database is not ready"); - const ready = yield* options.fs - .readFileString(readyPath) - .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ReadyMarker)))); - if (ready.version !== version || ready.runtime !== options.runtime) - return yield* errorFor( - "snapshot", - "Database readiness marker does not match the requested configuration", - ); - const digest = yield* descriptorDigest(version, key); - const paths = snapshotPaths(store); - const root = paths.root; - const source = paths.source; - const target = `${root}/entries/${digest}`; - const token = yield* options.crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("snapshot", cause)), - ); - const stage = `${root}/stages/${digest}-${token}`; - const descriptor = yield* encodeDescriptor({ - format: "supabase-database-snapshot-v1", - version, - runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase", - keyDigest: digest, - }); - const cacheOwnership = - store.backend === "host" - ? `; owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${root}` - : ""; - const command = withSnapshotLock( - root, - `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(`${root}/entries`)} ${shellQuote(`${root}/stages`)}; /usr/bin/busybox find ${shellQuote(`${root}/stages`)} -mindepth 1 -maxdepth 1 -exec /usr/bin/busybox rm -rf -- {} +; /usr/bin/busybox find ${shellQuote(`${root}/entries`)} -mindepth 1 -maxdepth 1 -name '*.retired' -exec /usr/bin/busybox rm -rf -- {} +; trap '/usr/bin/busybox rm -rf ${stage}${cacheOwnership}' EXIT; if [ -e ${shellQuote(`${source}/postmaster.pid`)} ]; then echo 'Cannot save a running database snapshot' >&2; exit 1; fi; if [ ! -f ${shellQuote(`${source}/PG_VERSION`)} ]; then echo 'Cannot save snapshot: PG_VERSION is missing' >&2; exit 1; fi; actual=$(/usr/bin/busybox cat ${shellQuote(`${source}/PG_VERSION`)}); if [ "$actual" != ${shellQuote(majorVersion(version))} ]; then echo 'Cannot save snapshot: PostgreSQL major does not match requested version' >&2; exit 1; fi; bad=$(/usr/bin/busybox find ${shellQuote(source)} \\( ! -type f ! -type d \\) -print -quit); if [ -n "$bad" ]; then echo "Cannot save snapshot: unsupported filesystem entry $bad" >&2; exit 1; fi; /usr/bin/busybox rm -rf ${shellQuote(stage)}; /usr/bin/busybox mkdir -p ${shellQuote(stage)}; /usr/local/bin/cp -a --reflink=auto ${shellQuote(source)} ${shellQuote(`${stage}/data`)}; printf '%s' ${shellQuote(descriptor)} > ${shellQuote(`${stage}/descriptor.json`)}; if [ -e ${shellQuote(target)} ]; then /usr/bin/busybox rm -rf ${shellQuote(`${target}.retired`)}; /usr/bin/busybox mv ${shellQuote(target)} ${shellQuote(`${target}.retired`)}; if ! /usr/bin/busybox mv ${shellQuote(stage)} ${shellQuote(target)}; then /usr/bin/busybox mv ${shellQuote(`${target}.retired`)} ${shellQuote(target)}; exit 1; fi; else /usr/bin/busybox mv ${shellQuote(stage)} ${shellQuote(target)}; fi; /usr/bin/busybox rm -rf ${shellQuote(`${target}.retired`)}; /usr/bin/busybox touch ${shellQuote(target)}; /usr/bin/busybox find ${shellQuote(`${root}/entries`)} -mindepth 1 -maxdepth 1 -type d ! -name ${shellQuote(digest)} ! -name '*.retired' -exec /usr/bin/busybox stat -c '%y %n' {} + | /usr/bin/busybox sort -r | /usr/bin/busybox tail -n +3 | /usr/bin/busybox cut -d ' ' -f 4- | /usr/bin/busybox xargs -r /usr/bin/busybox rm -rf`, - ); - yield* runHelper(command, paths.mounts, version); + yield* (yield* snapshots(store, version)).saveSnapshot(key); }).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), ); const restoreSnapshot = Effect.fn("DockerDatabaseStorage.restoreSnapshot")( @@ -1073,45 +1003,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.gen(function* () { yield* selected; const store = yield* getMarker; - const digest = yield* descriptorDigest(version, key); - const paths = snapshotPaths(store); - const root = paths.root; - const source = `${root}/entries/${digest}`; - const data = paths.source; - const token = yield* options.crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("snapshot", cause)), - ); - const stage = - store.backend === "host" - ? `/instance/.supabase-restore-${digest}` - : `${root}/stages/restore-${digest}-${token}`; - const descriptor = yield* encodeDescriptor({ - format: "supabase-database-snapshot-v1", - version, - runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase", - keyDigest: digest, - }); - const targetSetup = store.initialized - ? `test -d ${shellQuote(data)}` - : `/usr/bin/busybox mkdir -p ${shellQuote(data)}`; - const cacheOwnership = - store.backend === "host" - ? `; owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${root}` - : ""; - const command = withSnapshotLock( - root, - `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(`${root}/entries`)} ${shellQuote(`${root}/stages`)}; /usr/bin/busybox find ${shellQuote(`${root}/stages`)} -mindepth 1 -maxdepth 1 -exec /usr/bin/busybox rm -rf -- {} +; /usr/bin/busybox find ${shellQuote(`${root}/entries`)} -mindepth 1 -maxdepth 1 -name '*.retired' -exec /usr/bin/busybox rm -rf -- {} +; trap '/usr/bin/busybox rm -rf ${stage}${cacheOwnership}' EXIT; if ! ${targetSetup}; then echo 'Initialized restore target data directory is missing' >&2; exit 1; fi; bad=$(/usr/bin/busybox find ${shellQuote(data)} -mindepth 1 -print -quit); if [ -n "$bad" ]; then echo NONEMPTY; exit 0; fi; if [ ! -d ${shellQuote(source)} ]; then echo MISS; exit 0; fi; if [ ! -f ${shellQuote(`${source}/descriptor.json`)} ]; then echo 'Snapshot descriptor is missing' >&2; exit 1; fi; actual=$(/usr/bin/busybox cat ${shellQuote(`${source}/descriptor.json`)}); expected=${shellQuote(descriptor)}; if [ "$actual" != "$expected" ]; then echo 'Snapshot descriptor does not match requested identity' >&2; exit 1; fi; bad=$(/usr/bin/busybox find ${shellQuote(`${source}/data`)} \\( ! -type f ! -type d \\) -print -quit); if [ -n "$bad" ]; then echo "Snapshot contains unsupported filesystem entry $bad" >&2; exit 1; fi; if [ -e ${shellQuote(`${source}/data/postmaster.pid`)} ]; then echo 'Snapshot contains postmaster.pid' >&2; exit 1; fi; /usr/bin/busybox rm -rf ${shellQuote(stage)}; /usr/local/bin/cp -a --reflink=auto ${shellQuote(`${source}/data`)} ${shellQuote(stage)}; actual=$(/usr/bin/busybox cat ${shellQuote(`${stage}/PG_VERSION`)}); if [ "$actual" != ${shellQuote(majorVersion(version))} ]; then echo 'Snapshot PostgreSQL major does not match requested version' >&2; exit 1; fi; ${store.backend === "host" ? `/usr/bin/busybox chown -R 100:101 ${shellQuote(stage)};` : ""} /usr/bin/busybox rmdir ${shellQuote(data)}; /usr/bin/busybox mv ${shellQuote(stage)} ${shellQuote(data)}; /usr/bin/busybox touch ${shellQuote(source)}; echo HIT`, - ); - const result = yield* runHelper(command, paths.mounts, version); - if (result === "MISS") return false; - if (result === "NONEMPTY") - return yield* errorFor("restore", "Restore target data directory must be empty"); - if (result !== "HIT") - return yield* errorFor("restore", "Docker snapshot restore did not publish"); - yield* publishReadyMarker(version); + if (!(yield* (yield* snapshots(store, version)).restoreSnapshot(key))) return false; yield* writeMarker({ ...store, initialized: true }); return true; }).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), diff --git a/packages/stack/src/storage/DockerHelperRegistry.ts b/packages/stack/src/storage/DockerHelperRegistry.ts index 79ede8af52..90a8c224b4 100644 --- a/packages/stack/src/storage/DockerHelperRegistry.ts +++ b/packages/stack/src/storage/DockerHelperRegistry.ts @@ -1,4 +1,4 @@ -import { Effect, Ref, Scope, Semaphore } from "effect"; +import { Effect, Exit, Ref, Scope, Semaphore } from "effect"; export interface DockerHelperRegistry { /** Identifies helpers owned by one host process. */ @@ -14,73 +14,105 @@ export interface DockerHelperRegistry { readonly drop: (key: string) => Effect.Effect; } +interface Helper { + readonly id: string; + /** Distinguishes reopened helpers, whose ids can repeat. */ + readonly generation: number; + readonly close: (id: string) => Effect.Effect; +} + +/** Uses of one helper that may run at once; opening or closing it takes all of them. */ +const concurrentUses = 1024; + /** * One sleeping container per volume mount. Mounts are fixed when the container is created, - * and every database in a state directory uses that same volume. + * and every database in a state directory uses that same volume. Uses of one helper run + * concurrently; opening and closing it wait for them. */ export const makeDockerHelperRegistry = ( ownerId: string, ): Effect.Effect => Effect.gen(function* () { - const helpers = yield* Ref.make( - new Map< - string, - { readonly id: string; readonly close: (id: string) => Effect.Effect } - >(), - ); - const gate = yield* Semaphore.make(1); + const helpers = yield* Ref.make(new Map()); + const generations = yield* Ref.make(0); + const gates = yield* Ref.make(new Map()); const scope = yield* Scope.Scope; - const release = (id: string, close: (id: string) => Effect.Effect) => close(id); - yield* Scope.addFinalizer( - scope, - gate.withPermits(1)( - Effect.gen(function* () { - const current = yield* Ref.getAndSet(helpers, new Map()); - for (const entry of current.values()) yield* release(entry.id, entry.close); - }), - ), - ); - const forgetAndClose = (key: string): Effect.Effect => + const gateFor = (key: string) => + Ref.modify(gates, (map) => { + const existing = map.get(key); + if (existing !== undefined) return [existing, map]; + const gate = Semaphore.makeUnsafe(concurrentUses); + return [gate, new Map(map).set(key, gate)]; + }); + const exclusive = (key: string, effect: Effect.Effect) => + gateFor(key).pipe(Effect.flatMap((gate) => gate.withPermits(concurrentUses)(effect))); + // Only the helper a use saw is closed; a replacement opened since then stays. + const forget = (key: string, generation?: number): Effect.Effect => Effect.gen(function* () { const entry = (yield* Ref.get(helpers)).get(key); - if (entry === undefined) return; + if (entry === undefined || (generation !== undefined && entry.generation !== generation)) + return; yield* Ref.update(helpers, (map) => { const next = new Map(map); next.delete(key); return next; }); - yield* release(entry.id, entry.close); + yield* entry.close(entry.id); }); + yield* Scope.addFinalizer( + scope, + Effect.gen(function* () { + for (const key of (yield* Ref.get(gates)).keys()) yield* exclusive(key, forget(key)); + }), + ); + const openOnce = ( + key: string, + open: Effect.Effect, + close: (id: string) => Effect.Effect, + ) => + exclusive( + key, + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if ((yield* Ref.get(helpers)).has(key)) return; + const id = yield* restore(open); + const generation = yield* Ref.updateAndGet(generations, (value) => value + 1); + yield* Ref.update(helpers, (map) => + new Map(map).set(key, { + id, + generation, + close: (helperId) => + close(helperId).pipe(Effect.catch((cause) => Effect.logError(cause))), + }), + ); + }), + ), + ); const use = ( key: string, open: Effect.Effect, close: (id: string) => Effect.Effect, body: (id: string) => Effect.Effect, ): Effect.Effect => - gate.withPermit( + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const current = yield* Ref.get(helpers); - const existing = current.get(key); - const id = - existing?.id ?? - (yield* Effect.uninterruptibleMask((restore) => - restore(open).pipe( - Effect.flatMap((id) => - Ref.update(helpers, (map) => { - const next = new Map(map); - next.set(key, { - id, - close: (helperId) => - close(helperId).pipe(Effect.catch((cause) => Effect.logError(cause))), - }); - return next; - }).pipe(Effect.as(id)), - ), - ), - )); - return yield* body(id).pipe(Effect.onInterrupt(() => forgetAndClose(key))); + const gate = yield* gateFor(key); + let helper: Helper | undefined; + while (helper === undefined) { + yield* restore(gate.take(1)); + helper = (yield* Ref.get(helpers)).get(key); + if (helper !== undefined) break; + yield* gate.release(1); + yield* restore(openOnce(key, open, close)); + } + const exit = yield* Effect.exit(restore(body(helper.id))); + yield* gate.release(1); + // An interrupted exec can leave its command running inside the helper, so the + // helper is closed once its other uses finish. + if (Exit.hasInterrupts(exit)) yield* exclusive(key, forget(key, helper.generation)); + return yield* exit; }), ); - const drop = (key: string): Effect.Effect => gate.withPermit(forgetAndClose(key)); + const drop = (key: string): Effect.Effect => exclusive(key, forget(key)); return { ownerId, use, drop }; }); diff --git a/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts b/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts index adf48cda18..6b709d1433 100644 --- a/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts +++ b/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts @@ -2,18 +2,23 @@ import { expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Fiber, Ref } from "effect"; import { makeDockerHelperRegistry } from "./DockerHelperRegistry.ts"; +const counters = Effect.gen(function* () { + const opened = yield* Ref.make(0); + const closed = yield* Ref.make>([]); + const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( + Effect.map((count) => `helper-${String(count)}`), + ); + const close = (id: string) => Ref.update(closed, (ids) => [...ids, id]); + return { opened, closed, open, close }; +}); + it.effect("opens a helper once and closes it with the host scope", () => Effect.gen(function* () { - const opened = yield* Ref.make(0); - const closed = yield* Ref.make>([]); + const { opened, closed, open, close } = yield* counters; const seen = yield* Ref.make>([]); yield* Effect.scoped( Effect.gen(function* () { const registry = yield* makeDockerHelperRegistry("owner-one"); - const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( - Effect.map((count) => `helper-${String(count)}`), - ); - const close = (id: string) => Ref.update(closed, (ids) => [...ids, id]); yield* registry.use("volume", open, close, (id) => Ref.update(seen, (ids) => [...ids, id])); yield* registry.use("volume", open, close, (id) => Ref.update(seen, (ids) => [...ids, id])); expect(yield* Ref.get(opened)).toBe(1); @@ -27,12 +32,8 @@ it.effect("opens a helper once and closes it with the host scope", () => it.effect("keeps the helper when a command fails", () => Effect.gen(function* () { - const opened = yield* Ref.make(0); + const { opened, open, close } = yield* counters; const registry = yield* makeDockerHelperRegistry("owner-one"); - const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( - Effect.map((count) => `helper-${String(count)}`), - ); - const close = (_id: string) => Effect.void; const failed = yield* registry .use("volume", open, close, () => Effect.fail("script")) .pipe(Effect.exit); @@ -43,53 +44,98 @@ it.effect("keeps the helper when a command fails", () => }), ); -it.effect("closes only when an interrupted use entered the helper body", () => +it.effect("runs concurrent uses of one helper and of different helpers together", () => Effect.gen(function* () { - const opened = yield* Ref.make(0); - const closed = yield* Ref.make>([]); - const enteredFirst = yield* Deferred.make(); - const releaseFirst = yield* Deferred.make(); - const waiterStarted = yield* Deferred.make(); + const { opened, open, close } = yield* counters; const registry = yield* makeDockerHelperRegistry("owner-one"); - const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( - Effect.map((count) => `helper-${String(count)}`), - ); - const close = (id: string) => Ref.update(closed, (ids) => [...ids, id]); - const first = yield* registry + const release = yield* Deferred.make(); + const entered = yield* Ref.make>([]); + const allEntered = yield* Deferred.make(); + const hold = (label: string) => (id: string) => + Effect.gen(function* () { + const labels = yield* Ref.updateAndGet(entered, (values) => [...values, label]); + if (labels.length === 3) yield* Deferred.succeed(allEntered, undefined); + yield* Deferred.await(release); + return id; + }); + const uses = yield* Effect.all( + [ + registry.use("volume-a", open, close, hold("first")), + registry.use("volume-a", open, close, hold("second")), + registry.use("volume-b", open, close, hold("other")), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(allEntered); + yield* Deferred.succeed(release, undefined); + const ids = yield* Fiber.join(uses); + expect(ids[0]).toBe(ids[1]); + expect(ids[2]).not.toBe(ids[0]); + expect(yield* Ref.get(opened)).toBe(2); + }), +); + +it.effect("closes an interrupted use's helper after its other uses finish", () => + Effect.gen(function* () { + const { closed, open, close } = yield* counters; + const registry = yield* makeDockerHelperRegistry("owner-one"); + const enteredInterrupted = yield* Deferred.make(); + const enteredActive = yield* Deferred.make(); + const releaseActive = yield* Deferred.make(); + const interrupted = yield* registry .use("volume", open, close, () => - Deferred.succeed(enteredFirst, undefined).pipe( - Effect.andThen(Deferred.await(releaseFirst)), - ), + Deferred.succeed(enteredInterrupted, undefined).pipe(Effect.andThen(Effect.never)), ) .pipe(Effect.forkChild); - yield* Deferred.await(enteredFirst); - const waiter = yield* Effect.gen(function* () { - yield* Deferred.succeed(waiterStarted, undefined); - return yield* registry.use("volume", open, close, () => Effect.succeed("unexpected")); - }).pipe(Effect.forkChild); - yield* Deferred.await(waiterStarted); - const waiterExit = yield* Fiber.interrupt(waiter).pipe(Effect.andThen(Fiber.await(waiter))); - expect(Exit.isFailure(waiterExit)).toBe(true); - expect(yield* Ref.get(closed)).toEqual([]); - yield* Deferred.succeed(releaseFirst, undefined); - yield* Fiber.await(first); - - const reused = yield* registry.use("volume", open, close, (id) => Effect.succeed(id)); - expect(reused).toBe("helper-1"); - expect(yield* Ref.get(opened)).toBe(1); - - const enteredActive = yield* Deferred.make(); + yield* Deferred.await(enteredInterrupted); const active = yield* registry - .use("volume", open, close, () => - Deferred.succeed(enteredActive, undefined).pipe(Effect.andThen(Effect.never)), + .use("volume", open, close, (id) => + Deferred.succeed(enteredActive, undefined).pipe( + Effect.andThen(Deferred.await(releaseActive)), + Effect.as(id), + ), ) .pipe(Effect.forkChild); yield* Deferred.await(enteredActive); - const exit = yield* Fiber.interrupt(active).pipe(Effect.andThen(Fiber.await(active))); - expect(Exit.isFailure(exit)).toBe(true); - expect(yield* Ref.get(closed)).toEqual(["helper-1"]); + yield* Effect.sync(() => interrupted.interruptUnsafe()); + expect(yield* Ref.get(closed)).toEqual([]); + yield* Deferred.succeed(releaseActive, undefined); + expect(yield* Fiber.join(active)).toBe("helper-1"); + const exit = yield* Fiber.await(interrupted); + + expect(Exit.hasInterrupts(exit)).toBe(true); + expect(yield* Ref.get(closed)).toEqual(["helper-1"]); const replacement = yield* registry.use("volume", open, close, (id) => Effect.succeed(id)); expect(replacement).toBe("helper-2"); }), ); + +it.effect("keeps the helper when a use is interrupted before it starts", () => + Effect.gen(function* () { + const { closed, open, close } = yield* counters; + const registry = yield* makeDockerHelperRegistry("owner-one"); + const opening = yield* Deferred.make(); + const releaseOpen = yield* Deferred.make(); + const slowOpen = Deferred.succeed(opening, undefined).pipe( + Effect.andThen(Deferred.await(releaseOpen)), + Effect.andThen(open), + ); + const first = yield* registry + .use("volume", slowOpen, close, (id) => Effect.succeed(id)) + .pipe(Effect.forkChild); + yield* Deferred.await(opening); + const waiterStarted = yield* Deferred.make(); + const waiter = yield* Deferred.succeed(waiterStarted, undefined).pipe( + Effect.andThen(registry.use("volume", open, close, (id) => Effect.succeed(id))), + Effect.forkChild, + ); + yield* Deferred.await(waiterStarted); + const waiterExit = yield* Fiber.interrupt(waiter).pipe(Effect.andThen(Fiber.await(waiter))); + expect(Exit.hasInterrupts(waiterExit)).toBe(true); + yield* Deferred.succeed(releaseOpen, undefined); + + expect(yield* Fiber.join(first)).toBe("helper-1"); + expect(yield* Ref.get(closed)).toEqual([]); + }), +); diff --git a/packages/stack/src/storage/DockerSnapshotBackend.ts b/packages/stack/src/storage/DockerSnapshotBackend.ts new file mode 100644 index 0000000000..4f81881bfb --- /dev/null +++ b/packages/stack/src/storage/DockerSnapshotBackend.ts @@ -0,0 +1,127 @@ +import { Effect } from "effect"; +import { failureMessage } from "../internal/failure-message.ts"; +import { + DatabaseSnapshotError, + decodeSnapshotStop, + type SnapshotBackend, + SnapshotRun, + SnapshotStep, +} from "../services/DatabaseSnapshot.ts"; + +/** Quotes one POSIX shell word. */ +export const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +const busybox = "/usr/bin/busybox"; +// The database image ships GNU cp at /usr/local/bin for reflink copies. +const cp = "/usr/local/bin/cp"; +const stopMarker = "snapshot-stop:"; +const stepMarker = "snapshot-step:"; +const doneMarker = "snapshot-done"; + +const stepScript = (step: SnapshotStep, adoptOwner: string | undefined): string => { + const stop = (outcome: string) => `{ echo ${stopMarker}${outcome}; exit 0; }`; + const body = SnapshotStep.$match(step, { + Ensure: ({ directory }) => `${busybox} mkdir -p ${shellQuote(directory)}`, + Clear: ({ directory }) => + `[ ! -L ${shellQuote(directory)} ] || { echo ${shellQuote(`${directory} is a symbolic link`)} >&2; exit 1; }; ${busybox} mkdir -p ${shellQuote(directory)}; ${busybox} find ${shellQuote(directory)} -mindepth 1 -maxdepth 1 -exec ${busybox} rm -rf -- {} +`, + Recover: ({ stages, entries }) => + `for retired in ${shellQuote(stages)}/retired-*; do [ -d "$retired" ] || continue; digest=\${retired##*/retired-}; digest=\${digest%%-*}; if [ ! -e ${shellQuote(entries)}/"$digest" ]; then ${busybox} mkdir -p ${shellQuote(entries)}; ${busybox} mv "$retired" ${shellQuote(entries)}/"$digest"; fi; done`, + Expect: ({ path, present, otherwise }) => + `[ ${present ? "" : "! "}-e ${shellQuote(path)} ] || ${stop(otherwise)}`, + ExpectEmpty: ({ directory, otherwise }) => + `if [ -d ${shellQuote(directory)} ] && [ -n "$(${busybox} find ${shellQuote(directory)} -mindepth 1 -print -quit)" ]; then ${stop(otherwise)}; fi`, + ExpectText: ({ file, text, otherwise }) => + `if [ ! -f ${shellQuote(file)} ] || [ "$(${busybox} cat ${shellQuote(file)})" != ${shellQuote(text)} ]; then echo ${stopMarker}${otherwise}; ${busybox} cat ${shellQuote(file)} 2>/dev/null || true; exit 0; fi`, + Copy: ({ from, to }) => + `[ ! -e ${shellQuote(to)} ] || { echo "Snapshot copy destination exists" >&2; exit 1; }; unsupported=$(${busybox} find ${shellQuote(from)} \\( ! -type f ! -type d \\) -print -quit); [ -z "$unsupported" ] || { echo "Unsupported filesystem entry $unsupported" >&2; exit 1; }; ${cp} -a --reflink=auto ${shellQuote(from)} ${shellQuote(to)}`, + Adopt: ({ directory }) => + adoptOwner === undefined ? ":" : `${busybox} chown -R ${adoptOwner} ${shellQuote(directory)}`, + Write: ({ file, text }) => `printf '%s' ${shellQuote(text)} > ${shellQuote(file)}`, + Rename: ({ from, to, optional }) => + optional + ? `if [ -e ${shellQuote(from)} ] && [ ! -e ${shellQuote(to)} ]; then ${busybox} mv ${shellQuote(from)} ${shellQuote(to)}; fi` + : `${busybox} mkdir -p "$(${busybox} dirname ${shellQuote(to)})"; if [ -d ${shellQuote(to)} ]; then ${busybox} rmdir ${shellQuote(to)}; elif [ -e ${shellQuote(to)} ]; then echo ${shellQuote(`${to} already exists`)} >&2; exit 1; fi; ${busybox} mv ${shellQuote(from)} ${shellQuote(to)}`, + Remove: ({ path }) => `${busybox} rm -rf ${shellQuote(path)}`, + Touch: ({ path }) => `${busybox} touch ${shellQuote(path)}`, + // `%y` sorts by nanosecond modification time; entry names never contain spaces. + Prune: ({ directory, keep, except }) => + `${busybox} find ${shellQuote(directory)} -mindepth 1 -maxdepth 1 ! -name ${shellQuote(except)} -exec ${busybox} stat -c '%y %n' {} + | ${busybox} sort -r | ${busybox} tail -n +${keep + 1} | ${busybox} cut -d ' ' -f 4- | ${busybox} xargs -r ${busybox} rm -rf`, + }); + return `step=${step._tag.toLowerCase()}\n${body}`; +}; + +// A helper exec outlives an interrupted or killed client, so each script holds the store lock +// itself; this also serializes hosts that share one daemon. +const prologue = (root: string) => + [ + "step=preflight", + `for helper_binary in ${busybox} ${cp}; do command -v "$helper_binary" >/dev/null 2>&1 || { echo "Database snapshot helper image is missing required binary: $helper_binary" >&2; exit 97; }; done`, + "step=lock", + `${busybox} mkdir -p ${shellQuote(root)}`, + `exec 9>${shellQuote(`${root}/.lock`)}`, + `attempt=0; until lock_error=$(${busybox} flock -n 9 2>&1); do if [ -n "$lock_error" ]; then echo "$lock_error" >&2; exit 1; fi; if [ "$attempt" -ge 120 ]; then echo 'Timed out waiting for database snapshot lock' >&2; exit 1; fi; attempt=$((attempt + 1)); ${busybox} sleep 1; done`, + ].join("\n"); + +const helperError = (message: string, cause: unknown) => { + const step = new RegExp(`^${stepMarker}(\\w+)$`, "mu").exec(message); + return new DatabaseSnapshotError({ + operation: step?.[1] ?? "helper", + message: message.replace(new RegExp(`\\n?^${stepMarker}\\w+$`, "mu"), "").trim(), + cause, + }); +}; + +const parseRun = (output: string) => + Effect.gen(function* () { + const [first = "", ...rest] = output.split("\n"); + if (first === doneMarker) return SnapshotRun.Completed(); + if (!first.startsWith(stopMarker)) + return yield* new DatabaseSnapshotError({ + operation: "helper", + message: `Snapshot helper returned unexpected output: ${first}`, + }); + const outcome = yield* decodeSnapshotStop(first.slice(stopMarker.length)).pipe( + Effect.mapError( + (cause) => new DatabaseSnapshotError({ operation: "helper", message: cause.message }), + ), + ); + return SnapshotRun.Stopped({ outcome, text: rest.join("\n") }); + }); + +/** Runs each snapshot program as one POSIX shell script inside a storage helper container. */ +export const makeDockerSnapshotBackend = (options: { + readonly lockKey: string; + readonly root: string; + readonly data: string; + readonly restoreStages: string; + /** Owner applied to restored data before publication; unset keeps copied ownership. */ + readonly adoptOwner?: string; + /** Runs on every exit of the script, after the program. */ + readonly epilogue?: string; + readonly exec: (script: string) => Effect.Effect; +}): SnapshotBackend => { + const onExit = `status=$?; if [ "$status" -ne 0 ]; then echo "${stepMarker}$step" >&2; fi; ${options.epilogue ?? ":"}; exit "$status"`; + return { + lockKey: options.lockKey, + entries: `${options.root}/entries`, + stages: `${options.root}/stages`, + restoreStages: options.restoreStages, + data: options.data, + join: (...parts) => parts.join("/"), + run: (steps) => + options + .exec( + [ + "set -eu", + `trap ${shellQuote(onExit)} EXIT`, + prologue(options.root), + ...steps.map((step) => stepScript(step, options.adoptOwner)), + `echo ${doneMarker}`, + ].join("\n"), + ) + .pipe( + Effect.mapError((cause) => helperError(failureMessage(cause), cause)), + Effect.flatMap(parseRun), + ), + }; +}; From d021886ede50ff8b59b50950da4f7393e2d7fcef Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 28 Sep 2026 12:43:00 +0000 Subject: [PATCH 13/71] feat(cli): drive gen types languages from the @supabase/typegen registry (SDK-1956) (#6822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `gen types` no longer names its languages. `--lang` and the language-specific flags come from `@supabase/typegen`, the SDK team's language registry, which maps each language to its generator. Each registry entry either calls a generator in-process (TypeScript, Go, Python and Swift, the four bundled in `@supabase/postgrest-typegen`) or runs the language's own tool in the working directory with the introspected `GeneratorMetadata` document on stdin. `--lang dart` is the first out-of-process language: it runs `dart run supabase_typegen --output -` in the user's project. Adding a language is a pull request in `supabase/sdk` plus a bump of the one registry dependency here. ### What changed in `apps/cli/src/commands/gen/types/` - `types.languages.ts` (new): everything derived from the registry in one place. The `--lang` values, one optional Effect flag per user-facing option name (merged across the languages that declare it, with the union of their choices; a name declared with two kinds throws), the value-taking flag names for the argv scans, the documented defaults where every declaring language agrees, and `languageOptionValues`, which forwards only the values the user set so each language applies its own default. The pure functions take option specs, so the unit tests use synthetic specs rather than the registry's contents. - `types.command.ts`: `--lang` is a choice over the registry's names; the language flags are spread in from `types.languages.ts` after a collision check against the command's own flags, the global flags and Effect's built-ins. `--postgrest-v9-compat` is hidden. - `types.handler.ts`: the mutex groups, the changed-flag check and both value-flag scans include every registry language flag. `runGenerate` passes the language options plus the consumer setting `detect-one-to-one-relationships`, computed as before (`!(postgrestV9Compat || forcedV9)` on `--local`, `!postgrestV9Compat` elsewhere). Passing `--postgrest-v9-compat` prints a deprecation line on stderr before the guards run. - `types.generator.service.ts`: `GenTypesGenerateInput` carries `lang: string` and `options: OptionValues`. Two new tagged errors: `GenTypesToolNotInstalledError` (the registry's message verbatim, install hint included) and `GenTypesToolFailedError` (the tool's exit code and stderr in the message, also covering a rejected document version). - `types.generator.layer.ts`: opens the `DbConnection` session in its own scope, introspects in-process and closes the connection, then calls `findLanguage(lang).generate(metadata, declaredOptions, host)`, forwarding only the options the language declares. Registry errors map to the two new errors; anything else stays `GenTypesGenerationError`. The two tool errors never trigger the IPv4 pooler retry, since the tool runs after the connection succeeded and its stderr may name a host of its own. The registry sorts the metadata and returns complete file contents, so the layer's own sort and trailing newline are gone and output for the four existing languages is byte-identical. Out-of-process tools run in the directory the command was invoked from (`RuntimeInfo.cwd`), not the resolved Supabase workdir, so a Flutter app inside a monorepo resolves its own `supabase_typegen`. - `types.typegen-host.ts` (new): the registry `Host` on the Effect `ChildProcessSpawner`. The generator fiber owns the spawned tool through `Effect.runPromiseWith(context)` with the request's abort signal; the child runs in the directory the command was invoked from and inherits the CLI's whole environment; stdout, stderr and exit code are collected concurrently, with a signal-ended tool reported as a `null` exit code. On Windows the command is resolved through `PATH` and `PATHEXT` with the registry's `resolveWindowsCommand`, and a `.bat` or `.cmd` runs through the command interpreter with tokens holding whitespace or cmd.exe metacharacters quoted, since Flutter ships `dart` as `dart.bat`. The quoting and the script check are the registry's own `quoteForCmd` and `isWindowsScript` (supabase/sdk#231, in typegen 0.2.0), so this host and `createNodeHost` build the same line, and a token holding a double quote or a line break is refused rather than wrapped. A missing command rejects with `ENOENT`, which the registry turns into its install-hint error. `format` returns TypeScript unformatted so `oxfmt` stays out of the binary. - `SIDE_EFFECTS.md`: registry intro, the out-of-process subprocess row, two new exit-code rows, and the flag notes. Elsewhere: `error-actionability.ts` gains the `toolNotInstalled` and `toolFailed` presets and the error-tag fixture lists the two new tags; `db-target-flags.ts` and `docs-spec.tables.ts` take the language flags and their defaults from the registry instead of listing `swift-access-control` by hand; `tsconfig.types.json` keeps only the `@supabase/pg-topo` pin. `patches/@effect__platform-node-shared@4.0.0-rc.112.patch` attaches an `error` listener to a spawned child's stdin for its lifetime: the upstream sink listens only while writing and then waits for `finish` alone, so a tool that exits before reading its input turned the final flush's `EPIPE` into an uncaught exception that would have crashed the CLI. The host also feeds stdin from its own fiber rather than handing the spawner a stream, and `types.typegen-host.integration.test.ts` drives one kilobyte and four megabytes into a tool that exits at once; the small write is the one that reaches the unguarded `EPIPE` on Linux. ### Dependency `@supabase/typegen@0.2.1` from npm, pinned exactly, replaces the direct `@supabase/postgrest-typegen` dependency. The registry pins `@supabase/postgrest-typegen@0.3.0`, which compiles under this workspace's compiler options, so postgrest-typegen is type-checked from source through the registry like every other `bun`-condition package, and the `dist` types pin it used to need is gone. Both versions are excluded from the release-age policy because they were published this week. A new postgrest-typegen release reaches the CLI through a single registry bump. postgrest-typegen 0.3.1 declares `oxfmt` as an optional peer dependency and loads it only inside its default TypeScript formatter. `gen types` supplies its own identity formatter, so the compiled binary marks `oxfmt` external in `compile-options.ts` and ships without it. That one line replaces the `oxfmtStubPlugin` Bun plugin, the `oxfmt-stub.ts` module and their use in `scripts/build.ts`, `scripts/build-binary.ts` and `tools/release/local-release.ts`, all removed here. The binary is the same size as before, since the stub already kept `oxfmt` out. ### Behavior changes - `--lang` accepts `dart`. It needs the Dart SDK on `PATH` and `supabase_typegen` as a dependency of the project the command runs in; otherwise the error carries the install hint. - `--swift-access-control` accepts `private` and `package` in addition to `internal` and `public`, as the generator always did. - `--postgrest-v9-compat` is deprecated: hidden from help and docs, still honored with its `--db-url`-only rule, and it prints `Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection.` on stderr. - The four existing languages produce the same bytes as before against the databases checked, with one upstream exception: when two schemas hold an enum of the same name, postgrest-typegen 0.2.2 typed a Python column as `PublicStatus` and 0.3.0 types it as the schema-qualified `OtherStatus` and emits that alias, which is the fix for a name collision. TypeScript, Go and Swift were identical against the same database. - TypeScript output ends in one newline instead of two. The old code appended a newline after every generator regardless of what it produced, and the TypeScript template already ends in one; since typegen 0.2.1 (supabase/sdk#233, SDK-1961) the registry passes a generator's newline through instead of adding another. Go, Python and Swift are unaffected: their generators now emit the newline the CLI used to append. ### Notes for reviewers - `types.generator.integration.test.ts` runs the real generator layer against a fake `dart` on a temporary `PATH` through an empty fake database, covering the invocation directory, stdin delivery, exit codes, `ENOENT` and the rendered errors; `types.typegen-host.unit.test.ts` covers the host's result and rejection mapping and the Windows quoting; the e2e language list derives from the registry. Against a Docker Postgres with a small schema, `gen types --lang dart --db-url …` run from a Dart project and `dart run supabase_typegen --db-url …` produced byte-identical files. - The Windows path has only run against a fake spawner and filesystem; a run on a Windows machine with Flutter is still owed. Verified separately: `dart run supabase_typegen` keeps stdout clean on a cold `.dart_tool` cache, so no progress output can land in the generated file. ## Linked issue No GitHub issue; tracked in Linear as SDK-1956 (maintainer PR). Follow-up: SDK-1961 moves the trailing newline into the generators, with no change needed here when it lands. --- apps/cli/package.json | 2 +- apps/cli/scripts/build-binary.ts | 2 - apps/cli/scripts/build.ts | 3 +- apps/cli/scripts/bundle-externals.ts | 15 -- apps/cli/scripts/compile-options.ts | 3 + apps/cli/scripts/oxfmt-stub.ts | 7 - .../src/command-internal/db-target-flags.ts | 4 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 57 +++-- .../src/commands/gen/types/types.command.ts | 50 ++++- .../src/commands/gen/types/types.e2e.test.ts | 9 +- .../gen/types/types.errors.unit.test.ts | 32 +++ .../types/types.generator.integration.test.ts | 161 ++++++++++++++ .../gen/types/types.generator.layer.ts | 187 ++++++++++------- .../gen/types/types.generator.service.ts | 50 +++-- .../gen/types/types.generator.unit.test.ts | 119 ++++++----- .../src/commands/gen/types/types.handler.ts | 65 +++--- .../gen/types/types.integration.test.ts | 120 ++++++++--- .../src/commands/gen/types/types.languages.ts | 138 ++++++++++++ .../gen/types/types.languages.unit.test.ts | 88 ++++++++ .../types.typegen-host.integration.test.ts | 42 ++++ .../commands/gen/types/types.typegen-host.ts | 121 +++++++++++ .../gen/types/types.typegen-host.unit.test.ts | 196 ++++++++++++++++++ apps/cli/src/docs/docs-spec.tables.ts | 3 +- .../telemetry/__fixtures__/error-tags.txt | 2 + .../shared/telemetry/error-actionability.ts | 14 ++ apps/cli/tsconfig.types.json | 13 +- ...t__platform-node-shared@4.0.0-rc.112.patch | 15 ++ pnpm-lock.yaml | 46 ++-- pnpm-workspace.yaml | 4 +- tools/release/local-release.ts | 2 - 30 files changed, 1295 insertions(+), 275 deletions(-) delete mode 100644 apps/cli/scripts/bundle-externals.ts delete mode 100644 apps/cli/scripts/oxfmt-stub.ts create mode 100644 apps/cli/src/commands/gen/types/types.generator.integration.test.ts create mode 100644 apps/cli/src/commands/gen/types/types.languages.ts create mode 100644 apps/cli/src/commands/gen/types/types.languages.unit.test.ts create mode 100644 apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts create mode 100644 apps/cli/src/commands/gen/types/types.typegen-host.ts create mode 100644 apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts create mode 100644 patches/@effect__platform-node-shared@4.0.0-rc.112.patch diff --git a/apps/cli/package.json b/apps/cli/package.json index 0a7c8b2fed..774a49e804 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -59,9 +59,9 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.52", "@supabase/pg-topo": "1.0.0-alpha.6", - "@supabase/postgrest-typegen": "0.2.2", "@supabase/stack": "workspace:*", "@supabase/supabase-js": "catalog:", + "@supabase/typegen": "0.2.1", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/pg": "^8.23.1", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 0145a1dc20..d2e9a794f7 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,7 +1,6 @@ import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; import { Effect } from "effect"; -import { oxfmtStubPlugin } from "./bundle-externals.ts"; import { compileOptions } from "./compile-options.ts"; /** @@ -23,7 +22,6 @@ const result = await Bun.build({ entrypoints: [entrypoint], compile: { outfile }, ...compileOptions, - plugins: [oxfmtStubPlugin], define: { SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 9874b44824..8d5589e61e 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -7,7 +7,6 @@ import { parseArgs } from "node:util"; import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; -import { oxfmtStubPlugin } from "./bundle-externals.ts"; import { compileOptions } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -119,7 +118,7 @@ async function runBunBuild(config: Bun.BuildConfig) { const result = await Bun.build({ ...config, ...compileOptions, - plugins: [...(config.plugins ?? []), oxfmtStubPlugin], + plugins: config.plugins ?? [], }); for (const log of result.logs) { console.warn(log); diff --git a/apps/cli/scripts/bundle-externals.ts b/apps/cli/scripts/bundle-externals.ts deleted file mode 100644 index 77f78b623e..0000000000 --- a/apps/cli/scripts/bundle-externals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import path from "node:path"; -import type { BunPlugin } from "bun"; - -const oxfmtStub = path.join(import.meta.dir, "oxfmt-stub.ts"); - -/** - * `Bun.build` `alias` does not rewrite imports inside dependencies. - * `@supabase/postgrest-typegen` imports `oxfmt`, which the CLI never calls. - */ -export const oxfmtStubPlugin: BunPlugin = { - name: "oxfmt-stub", - setup(build) { - build.onResolve({ filter: /^oxfmt$/ }, () => ({ path: oxfmtStub })); - }, -}; diff --git a/apps/cli/scripts/compile-options.ts b/apps/cli/scripts/compile-options.ts index 19392766ac..9f73a93e3d 100644 --- a/apps/cli/scripts/compile-options.ts +++ b/apps/cli/scripts/compile-options.ts @@ -7,4 +7,7 @@ export const compileOptions = { bytecode: true, bytecodeDepth: 2, format: "esm" as const, + // `oxfmt` is an optional peer of `@supabase/postgrest-typegen`, loaded only by the default + // TypeScript formatter; `gen types` supplies its own, so the binary ships without it. + external: ["oxfmt"], }; diff --git a/apps/cli/scripts/oxfmt-stub.ts b/apps/cli/scripts/oxfmt-stub.ts deleted file mode 100644 index 552f1fe19e..0000000000 --- a/apps/cli/scripts/oxfmt-stub.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Stand-in for the `oxfmt` package `@supabase/postgrest-typegen` imports. - * `gen types` passes its own formatter and never calls this. - */ -export function format(): never { - throw new Error("oxfmt is not bundled in the Supabase CLI"); -} diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 74c8f86113..59a2a106ed 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -9,6 +9,8 @@ * skipped during the scan, so e.g. `--schema --linked` does not misdetect `--linked` as changed. */ +import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../commands/gen/types/types.languages.ts"; + export type DbConnType = "db-url" | "linked" | "local"; export interface DbTargetSelection { @@ -108,7 +110,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "source", "status", "sub", - "swift-access-control", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "tail", "template", "timestamp", diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 24447d7f86..5db2ed9e28 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -1,7 +1,13 @@ # `supabase gen types` -Generates PostgREST client types in-process via `@supabase/postgrest-typegen` -against a direct PostgreSQL connection. `--linked`/`--project-id` TypeScript +Generates PostgREST client types against a direct PostgreSQL connection. +Introspection runs in-process via `@supabase/postgrest-typegen`; the language +comes from the `@supabase/typegen` registry, which lists every `--lang` value and +either calls that language's generator in-process (TypeScript, Go, Python, +Swift today) or runs the language's own tool in the directory the command was run from, with the +introspected document on stdin (`--lang dart` runs `dart run supabase_typegen +--output -`). A bump of that dependency can add a `--lang` value or a language +flag; the CLI names no language itself. `--linked`/`--project-id` TypeScript output still comes from the Management API; every other language and target (including `--local` and `--db-url`) connects to the database and introspects it directly — no pg-meta container is involved. When `[experimental].stack` @@ -54,14 +60,19 @@ workdir). `--local` and `--db-url` do not call the Management API. ## Subprocesses -| Command | When | Purpose | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ---------------------------------- | -| `docker`/`podman container inspect supabase_db_` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running | +| Command | When | Purpose | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `docker`/`podman container inspect supabase_db_` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running | +| `dart run supabase_typegen --output -` | `--lang dart` on every target, in the directory the command was run from | generate the types from the `GeneratorMetadata` JSON on stdin | -Generation itself runs in-process and never shells out. On a native or -Docker-based managed stack (`[experimental].stack` on), `--local` never -inspects a container; it resolves the stack's database connection through -`DbConfigResolver` the same way `--db-url` does. +Any other `--lang` whose registry entry is out-of-process runs its own tool +the same way: stdout is the output, stderr is folded into the error on +failure. The tool inherits the CLI's entire environment, so a `PUB_CACHE` or +proxy setting reaches it unchanged. Introspection and the in-process +languages never shell out. On a native or Docker-based managed stack +(`[experimental].stack` on), `--local` never inspects a container; it +resolves the stack's database connection through `DbConfigResolver` the same +way `--db-url` does. ## Environment Variables @@ -78,6 +89,7 @@ inspects a container; it resolves the stack's database connection through | `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | | `SUPABASE_SERVICES_HOSTNAME` | host used to reach the local database on the legacy Docker Compose stack | no (defaults to `127.0.0.1`) | | `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | +| `PATH`, `PATHEXT`, `ComSpec` | find the tool of an out-of-process `--lang`; `PATHEXT` and `ComSpec` matter on Windows only | no (`PATHEXT` defaults to `.COM;.EXE;.BAT;.CMD`, `ComSpec` to `cmd.exe`; without `PATH` the tool is reported as not installed) | ## Exit Codes @@ -96,6 +108,8 @@ inspects a container; it resolves the stack's database connection through | `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a schema-selecting path (`GenTypesMissingProjectConfigError`) — a DEFAULTED workdir keeps the embedded-default fallback instead | | `1` | a resolved preview branch config has no `db_user`/`db_pass` (`GenTypesBranchCredentialsUnavailableError`) | | `1` | API error or database connection/introspection/generation failure (`GenTypesGenerationError`) | +| `1` | an out-of-process language's toolchain or package is missing from the current directory; the message carries the registry's install hint (`GenTypesToolNotInstalledError`) | +| `1` | an out-of-process language's tool exited unsuccessfully or rejected the metadata document; the message carries its stderr (`GenTypesToolFailedError`) | ## Output @@ -170,15 +184,24 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only Management API and is unaffected by this change, so it can differ from local output on these jsonb cases until the hosted service adopts the same generator. - `--schema` / `-s` accepts a comma-separated list of schemas to include. -- `--swift-access-control` accepts `internal` (default) or `public`. It is - mutually exclusive with an _explicit_ `--linked`/`--project-id`; on the `--local`, - `--db-url`, and implicit-linked-fallback paths it is always applied - regardless of `--lang`. -- `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below. - It must be used together with `--db-url` (error: +- Language flags are rendered from the registry's user-facing option specs; today that is + `--swift-access-control`, which accepts `internal` (default), `public`, `private` or + `package`. Every such flag is mutually exclusive with an _explicit_ + `--linked`/`--project-id`; on the `--local`, `--db-url`, and implicit-linked-fallback + paths the flags are parsed regardless of `--lang`, and only the values the chosen + language declares reach its generator. +- `--postgrest-v9-compat` is deprecated and hidden from help and docs. It still turns + one-to-one relationship detection off (the registry's `detect-one-to-one-relationships` + consumer option), must be used together with `--db-url` (error: `--postgrest-v9-compat must used together with --db-url` — note the typo, preserved - intentionally). `--local` still forces v9 compat when the local PostgREST image tag - contains `v9`. + intentionally), and prints this line on stderr when passed: + + ``` + Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection. + ``` + + `--local` still forces detection off when the local PostgREST image tag contains `v9`. + - `--query-timeout` sets the maximum time allowed for introspection (default 15s), applied both as the connection's server-side `statement_timeout` and as a connect timeout. It is mutually exclusive with an _explicit_ `--linked`/`--project-id`; on diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 6ab72759ce..db87d011f7 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -3,12 +3,15 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; +import { GLOBAL_FLAGS } from "../../../command-internal/global-flags.ts"; +import { + PERSISTENT_VALUE_FLAG_NAMES, + PERSISTENT_VALUE_FLAG_SHORTHANDS, +} from "../../../shared/cli/cobra-flag-groups.ts"; import { genTypes } from "./types.handler.ts"; +import { GEN_TYPES_LANGUAGES, genTypesLanguageFlags } from "./types.languages.ts"; import { genTypesRuntimeLayer } from "./types.layers.ts"; -const LANG_VALUES = ["typescript", "go", "swift", "python"] as const; -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."), @@ -26,7 +29,7 @@ const config = { Flag.withDescription("Generate types from a project ID."), Flag.optional, ), - lang: Flag.choice("lang", LANG_VALUES).pipe( + lang: Flag.choice("lang", GEN_TYPES_LANGUAGES).pipe( Flag.withDescription("Output language of the generated types. (default typescript)"), Flag.withDefault("typescript"), ), @@ -39,12 +42,10 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - swiftAccessControl: Flag.choice("swift-access-control", SWIFT_ACCESS_CONTROL_VALUES).pipe( - Flag.withDescription("Access control for Swift generated types. (default internal)"), - Flag.withDefault("internal"), - ), + // Hidden: Effect V4 has no `Flag.withDeprecated`; the handler prints cobra's deprecation line. postgrestV9Compat: Flag.boolean("postgrest-v9-compat").pipe( Flag.withDescription("Generate types compatible with PostgREST v9 and below."), + Flag.withHidden, Flag.withDefault(false), ), queryTimeout: Flag.string("query-timeout").pipe( @@ -53,12 +54,39 @@ const config = { ), } as const; +/** Every flag name `gen types` already answers to: its own, the globals, and Effect's built-ins. */ +const GEN_TYPES_RESERVED_FLAG_NAMES: ReadonlyArray = [ + "local", + "linked", + "db-url", + "project-id", + "lang", + "schema", + "s", + "postgrest-v9-compat", + "query-timeout", + ...GLOBAL_FLAGS.map((flag) => flag.id), + ...PERSISTENT_VALUE_FLAG_NAMES, + ...PERSISTENT_VALUE_FLAG_SHORTHANDS.keys(), + "help", + "h", + "version", + "v", + "wizard", + "completions", + "log-level", +]; + +const flagsConfig = { ...config, ...genTypesLanguageFlags(GEN_TYPES_RESERVED_FLAG_NAMES) }; + const commandConfig = { - ...config, + ...flagsConfig, language: Argument.string("language").pipe(Argument.optional, Param.withHidden), } as const; -export type GenTypesFlags = CliCommand.Command.Config.Infer; +/** The registry's language flags are only known at runtime; read them through `languageOptionValues`. */ +export type GenTypesFlags = CliCommand.Command.Config.Infer & + Readonly>; export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withDescription("Generate types from Postgres schema."), @@ -83,7 +111,7 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( ]), Command.withHandler((flags) => genTypes(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-id"], config }), + withCommandTelemetry({ flags, safeFlags: ["project-id"], config: flagsConfig }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 48e1d652ad..0eec083ffe 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -1,3 +1,4 @@ +import { languages } from "@supabase/typegen"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Clock, Config, Data, Effect, FileSystem, Option, Path, Schedule } from "effect"; @@ -15,8 +16,10 @@ import { resolveDeadline, } from "../../../../tests/helpers/docker-image.ts"; -const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; -type TypegenLang = (typeof TYPEGEN_LANGS)[number]; +const TYPEGEN_LANGS: ReadonlyArray = languages + .filter((language) => language.inProcess) + .map((language) => language.name); +type TypegenLang = string; const LOCAL_POSTGRES_IMAGE = dockerfileServiceImage("pg"); const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; @@ -297,6 +300,8 @@ function expectLanguageShape(lang: TypegenLang, stdout: string) { case "python": expect(stdout).toContain("from __future__ import annotations"); break; + default: + throw new Error(`no shape assertion for the registry language ${lang}`); } } diff --git a/apps/cli/src/commands/gen/types/types.errors.unit.test.ts b/apps/cli/src/commands/gen/types/types.errors.unit.test.ts index b34c8171cd..dc6a9f5496 100644 --- a/apps/cli/src/commands/gen/types/types.errors.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.errors.unit.test.ts @@ -8,6 +8,38 @@ import { GenTypesLocalDbInspectError, GenTypesLocalDbNotRunningError, } from "./types.errors.ts"; +import { + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; + +describe("GenTypesToolNotInstalledError actionability", () => { + it("classifies a missing language toolchain as user-actionable without a canonical remedy", () => { + const error = new GenTypesToolNotInstalledError({ + message: + "Generating dart types needs `dart`, which was not found on PATH. Install the Dart SDK.", + }); + + const result = classifyCliErrorActionability(error); + expect(result.error_kind).toBe(actionability.toolNotInstalled.error_kind); + expect(result.error_category).toBe(actionability.toolNotInstalled.error_category); + expect(result.has_suggestion).toBe(false); + expect(result.error_fingerprint).toBe("tag:GenTypesToolNotInstalledError"); + }); +}); + +describe("GenTypesToolFailedError actionability", () => { + it("classifies a failing language tool as unknown with the rerun-debug suggestion", () => { + const error = new GenTypesToolFailedError({ + message: "`dart run supabase_typegen --output -` exited with code 78.", + }); + + const result = classifyCliErrorActionability(error); + expect(result.error_kind).toBe(actionability.toolFailed.error_kind); + expect(result.suggestion_type).toBe(actionability.toolFailed.suggestion_type); + expect(result.error_fingerprint).toBe("tag:GenTypesToolFailedError"); + }); +}); describe("GenTypesBranchCredentialsUnavailableError actionability", () => { it("classifies a branch config without credentials as an API response problem", () => { diff --git a/apps/cli/src/commands/gen/types/types.generator.integration.test.ts b/apps/cli/src/commands/gen/types/types.generator.integration.test.ts new file mode 100644 index 0000000000..4209a07517 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.generator.integration.test.ts @@ -0,0 +1,161 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect"; +import type { DbSession } from "../../../command-internal/db-connection.service.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { genTypesGeneratorLayer } from "./types.generator.layer.ts"; +import { + type GenTypesGenerateInput, + GenTypesGenerator, + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; + +const unused = (what: string) => () => Effect.die(`${what} is not part of this test`); + +/** A connection whose every introspection query returns no rows: a valid, empty database. */ +const emptyDatabase = Layer.succeed(DbConnection, { + connect: () => + Effect.succeed({ + exec: unused("exec"), + execBatch: unused("execBatch"), + query: () => Effect.succeed([]), + queryRaw: unused("queryRaw"), + extensionExists: unused("extensionExists"), + copyToCsv: unused("copyToCsv"), + }), +}); + +const runtimeIn = (cwd: string) => + Layer.succeed(RuntimeInfo, { + cwd, + platform: process.platform, + arch: process.arch, + homeDir: cwd, + execPath: process.execPath, + pid: process.pid, + }); + +/** The generator layer as the command wires it, with the environment the layer reads through Config. */ +const layerIn = (cwd: string, env: Record) => + genTypesGeneratorLayer.pipe( + Layer.provide(emptyDatabase), + Layer.provide(runtimeIn(cwd)), + Layer.provide(BunServices.layer), + Layer.provide(Layer.succeed(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(env))), + ); + +const input = (lang: string): GenTypesGenerateInput => ({ + conn: { + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }, + isLocal: true, + dnsResolver: "native", + lang, + includedSchemas: ["public"], + options: {}, +}); + +const generate = (lang: string) => + Effect.gen(function* () { + const generator = yield* GenTypesGenerator; + return yield* Effect.scoped(generator.generate(input(lang))); + }); + +/** A stand-in `dart`: fails like the real tool when `.fail` sits in its cwd, else echoes its stdin. */ +const FAKE_DART = `#!/bin/sh +if [ -f "$(pwd)/.fail" ]; then + echo "The project is on Dart 3.0.0, but the generated code needs Dart 3.8.0 or newer." >&2 + exit 78 +fi +printf 'args: %s\\n' "$*" +printf 'cwd: %s\\n' "$(pwd)" +cat +`; + +const withFakeDart = ( + body: (context: { readonly cwd: string; readonly bin: string }) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "gen-types-dart-" }), + ); + const bin = path.join(cwd, "bin"); + yield* fs.makeDirectory(bin); + yield* fs.writeFileString(path.join(bin, "dart"), FAKE_DART); + yield* fs.chmod(path.join(bin, "dart"), 0o755); + return yield* body({ cwd, bin }); + }).pipe(Effect.provide(BunServices.layer)); + +describe.skipIf(process.platform === "win32")( + "genTypesGeneratorLayer with a real subprocess", + () => { + it.effect("runs the language tool in the invocation directory with the document on stdin", () => + withFakeDart(({ cwd, bin }) => + generate("dart").pipe( + Effect.tap((output) => + Effect.sync(() => { + expect(output).toContain("args: run supabase_typegen --output -\n"); + expect(output).toContain(`cwd: ${cwd}\n`); + expect(output).toContain('"schemas":[]'); + expect(output).toContain('"version":1'); + }), + ), + Effect.provide(layerIn(cwd, { PATH: `${bin}:/usr/bin:/bin` })), + ), + ), + ); + + it.effect("reports a missing toolchain with the registry's install hint", () => + withFakeDart(({ cwd }) => + Effect.flip(generate("dart")).pipe( + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(GenTypesToolNotInstalledError); + expect(error.message).toContain("Install the Dart SDK"); + }), + ), + Effect.provide(layerIn(cwd, { PATH: `${cwd}/nowhere:/usr/bin:/bin` })), + ), + ), + ); + + it.effect("surfaces the tool's stderr when it exits unsuccessfully", () => + withFakeDart(({ cwd, bin }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(cwd, ".fail"), ""); + const error = yield* Effect.flip(generate("dart")); + expect(error).toBeInstanceOf(GenTypesToolFailedError); + expect(error.message).toContain("exited with code 78"); + expect(error.message).toContain("needs Dart 3.8.0 or newer"); + }).pipe( + Effect.provide( + Layer.merge(layerIn(cwd, { PATH: `${bin}:/usr/bin:/bin` }), BunServices.layer), + ), + ), + ), + ); + + it.effect("still generates in-process languages without spawning anything", () => + withFakeDart(({ cwd }) => + generate("typescript").pipe( + Effect.tap((output) => + Effect.sync(() => { + expect(output).toContain("export type Database"); + }), + ), + Effect.provide(layerIn(cwd, { PATH: "/usr/bin:/bin" })), + ), + ), + ); + }, +); diff --git a/apps/cli/src/commands/gen/types/types.generator.layer.ts b/apps/cli/src/commands/gen/types/types.generator.layer.ts index d9f660f0db..f94fceec4f 100644 --- a/apps/cli/src/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/commands/gen/types/types.generator.layer.ts @@ -1,98 +1,131 @@ -import { Effect, Layer } from "effect"; import { - generateGo, - generatePython, - generateSwift, - generateTypescript, + findLanguage, + InvalidOptionError, introspect, - sortGeneratorMetadata, - type GeneratorMetadata, + type OptionValue, + type OptionValues, type Queryable, -} from "@supabase/postgrest-typegen"; + ToolFailedError, + ToolNotInstalledError, + type TypegenLanguage, +} from "@supabase/typegen"; +import { Config, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { GenTypesGenerationError, GenTypesGenerator } from "./types.generator.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + type GenTypesGenerateError, + GenTypesGenerationError, + GenTypesGenerator, + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; +import { makeTypegenHost } from "./types.typegen-host.ts"; -/** - * pg-meta printed generated output through `console.log`, which appends a newline regardless of - * what the generator already emitted — the TypeScript generator ends with one, so its output - * gained a blank final line. Appending unconditionally keeps every language byte-identical. - */ -function withTrailingNewline(code: string): string { - return `${code}\n`; -} +/** Only the values the language declares; the registry rejects names it does not know. */ +export const declaredOptions = (language: TypegenLanguage, values: OptionValues): OptionValues => { + const declared: Record = {}; + for (const spec of language.options) { + const value = values[spec.name]; + if (value !== undefined) declared[spec.name] = value; + } + return declared; +}; + +const generationError = (lang: string, cause: unknown): GenTypesGenerationError => + new GenTypesGenerationError({ + message: `failed to generate ${lang} types: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }); + +/** The CLI error for whatever `TypegenLanguage.generate` rejected with. */ +export const mapRegistryError = (lang: string, cause: unknown): GenTypesGenerateError => { + if (cause instanceof ToolNotInstalledError) { + return new GenTypesToolNotInstalledError({ message: cause.message }); + } + if (cause instanceof ToolFailedError) { + return new GenTypesToolFailedError({ message: cause.message, cause }); + } + if (cause instanceof InvalidOptionError) { + return new GenTypesGenerationError({ message: cause.message, cause }); + } + return generationError(lang, cause); +}; + +const optionalEnv = (name: string) => + Config.option(Config.string(name)).pipe(Effect.map(Option.getOrUndefined)); /** - * Live `GenTypesGenerator`: opens a `DbConnection` session, adapts it to the generator's - * `Queryable` contract, and runs introspection and code generation in-process, replacing the - * pg-meta Docker container `gen types` previously shelled out to. + * Live `GenTypesGenerator`: opens a `DbConnection` session, adapts it to typegen's `Queryable` + * contract, introspects in-process, then hands the metadata to the language's registry entry, + * which calls its generator in-process or runs the language's own tool in the working directory. */ export const genTypesGeneratorLayer = Layer.effect( GenTypesGenerator, Effect.gen(function* () { const dbConn = yield* DbConnection; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* RuntimeInfo; + const lookupEnv = { + PATH: yield* optionalEnv("PATH"), + PATHEXT: yield* optionalEnv("PATHEXT"), + ComSpec: yield* optionalEnv("ComSpec"), + }; return GenTypesGenerator.of({ generate: (input) => Effect.gen(function* () { - const session = yield* dbConn.connect(input.conn, { - isLocal: input.isLocal, - dnsResolver: input.dnsResolver, - }); - // `session.query` needs no services, but running it through the current fiber's - // context (rather than a bare detached `Effect.runPromise`) keeps the Promise bridge - // anchored to this generator effect instead of a disconnected top-level runtime. - const runQuery = Effect.runPromiseWith(yield* Effect.context()); - const toGenerationError = (cause: unknown) => - new GenTypesGenerationError({ - message: `failed to generate ${input.lang} types: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - cause, + const language = findLanguage(input.lang); + if (language === undefined) { + return yield* new GenTypesGenerationError({ + message: `failed to generate ${input.lang} types: unknown language`, }); - const generateSource = (metadata: GeneratorMetadata) => { - switch (input.lang) { - case "typescript": - return Effect.tryPromise({ - try: () => - generateTypescript(metadata, { - detectOneToOneRelationships: input.detectOneToOneRelationships, - // TypeScript is emitted as the generator wrote it. oxfmt is not bundled. - format: (code) => Promise.resolve(code), - }), - catch: toGenerationError, - }); - case "go": - return Effect.try({ try: () => generateGo(metadata), catch: toGenerationError }); - case "python": - return Effect.try({ - try: () => generatePython(metadata), - catch: toGenerationError, - }); - case "swift": - return Effect.try({ - try: () => generateSwift(metadata, { accessControl: input.swiftAccessControl }), - catch: toGenerationError, - }); - } - }; - const metadata = yield* Effect.tryPromise({ - try: (signal) => { - // `signal` aborts when this generate call is interrupted, so forwarding it to - // every `runQuery` stops an in-flight introspection query instead of leaving it - // detached from the fiber that started it. - const queryable: Queryable = { - query: (sql) => - runQuery(session.query(sql), { signal }).then((rows) => ({ rows: [...rows] })), - }; - return introspect(queryable, { includedSchemas: [...input.includedSchemas] }); - }, - catch: toGenerationError, - }).pipe( - Effect.flatMap((raw) => - Effect.try({ try: () => sortGeneratorMetadata(raw), catch: toGenerationError }), - ), + } + // Both Promise bridges run through the current fiber's context (rather than a bare + // detached `Effect.runPromise`) so they stay anchored to this generator effect instead + // of a disconnected top-level runtime. + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const toGenerationError = (cause: unknown) => generationError(input.lang, cause); + // The session closes before an out-of-process tool starts, so no connection idles + // while, say, `dart run` compiles. + const metadata = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(input.conn, { + isLocal: input.isLocal, + dnsResolver: input.dnsResolver, + }); + return yield* Effect.tryPromise({ + try: (signal) => { + // `signal` aborts when this generate call is interrupted, so forwarding it to + // every query stops an in-flight introspection query instead of leaving it + // detached from the fiber that started it. + const queryable: Queryable = { + query: (sql) => + runPromise(session.query(sql), { signal }).then((rows) => ({ + rows: [...rows], + })), + }; + return introspect(queryable, { includedSchemas: [...input.includedSchemas] }); + }, + catch: toGenerationError, + }); + }), ); - return withTrailingNewline(yield* generateSource(metadata)); + const host = makeTypegenHost({ + cwd: runtime.cwd, + env: lookupEnv, + platform: runtime.platform, + spawner, + runPromise, + }); + return yield* Effect.tryPromise({ + try: (signal) => + language.generate(metadata, declaredOptions(language, input.options), { + ...host, + signal, + }), + catch: (cause) => mapRegistryError(input.lang, cause), + }); }), }); }), diff --git a/apps/cli/src/commands/gen/types/types.generator.service.ts b/apps/cli/src/commands/gen/types/types.generator.service.ts index 14e04ea579..8fcb069039 100644 --- a/apps/cli/src/commands/gen/types/types.generator.service.ts +++ b/apps/cli/src/commands/gen/types/types.generator.service.ts @@ -1,3 +1,4 @@ +import type { OptionValues } from "@supabase/typegen"; import { Context, Data, type Effect, type Scope } from "effect"; import { actionability, @@ -10,30 +11,27 @@ import type { PgConnInput, } from "../../../command-internal/db-connection.service.ts"; -/** Output language `gen types` can produce, mirroring the `@supabase/postgrest-typegen` generators. */ -type GenTypesLanguage = "typescript" | "go" | "python" | "swift"; - -/** - * Swift access-control levels `gen types` exposes. The underlying generator also accepts - * `"private"`/`"package"`, which this command does not surface. - */ -type GenTypesSwiftAccessControl = "internal" | "public"; - export interface GenTypesGenerateInput { readonly conn: PgConnInput; readonly isLocal: boolean; readonly dnsResolver: DbConnectOptions["dnsResolver"]; - readonly lang: GenTypesLanguage; + /** A `--lang` value: the name of one of the registry's `languages`. */ + readonly lang: string; readonly includedSchemas: ReadonlyArray; - readonly detectOneToOneRelationships: boolean; - readonly swiftAccessControl: GenTypesSwiftAccessControl; + /** Registry option values by name; ones the language does not declare are dropped. */ + readonly options: OptionValues; } +export type GenTypesGenerateError = + | GenTypesGenerationError + | GenTypesToolNotInstalledError + | GenTypesToolFailedError; + interface GenTypesGeneratorShape { /** Connects to `input.conn`, introspects it, and generates `input.lang` source. */ readonly generate: ( input: GenTypesGenerateInput, - ) => Effect.Effect; + ) => Effect.Effect; } /** Introspection or code generation failed against the target database's schema. */ @@ -46,10 +44,28 @@ export class GenTypesGenerationError extends Data.TaggedError("GenTypesGeneratio } } -/** - * Generates PostgREST client types in-process via `@supabase/postgrest-typegen`, replacing the - * pg-meta Docker container `gen types` previously shelled out to. - */ +/** An out-of-process language's toolchain is missing; the message carries the install hint. */ +export class GenTypesToolNotInstalledError extends Data.TaggedError( + "GenTypesToolNotInstalledError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.toolNotInstalled; + } +} + +/** An out-of-process language's tool failed; the message carries its stderr. */ +export class GenTypesToolFailedError extends Data.TaggedError("GenTypesToolFailedError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.toolFailed; + } +} + +/** Introspects the target database and generates `lang` through the `@supabase/typegen` registry. */ export class GenTypesGenerator extends Context.Service()( "supabase/cli/GenTypesGenerator", ) {} diff --git a/apps/cli/src/commands/gen/types/types.generator.unit.test.ts b/apps/cli/src/commands/gen/types/types.generator.unit.test.ts index d91d9742f3..67b5105d15 100644 --- a/apps/cli/src/commands/gen/types/types.generator.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.generator.unit.test.ts @@ -1,56 +1,79 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; import { - generateGo, - generatePython, - generateSwift, - generateTypescript, - introspect, - sortGeneratorMetadata, - type GeneratorMetadata, -} from "@supabase/postgrest-typegen"; + findLanguage, + InvalidOptionError, + ToolFailedError, + ToolNotInstalledError, +} from "@supabase/typegen"; +import { declaredOptions, mapRegistryError } from "./types.generator.layer.ts"; +import { + GenTypesGenerationError, + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; -const emptyMetadata: GeneratorMetadata = { - version: 1, - schemas: [{ id: 1, name: "public", owner: "postgres" }], - tables: [], - views: [], - materializedViews: [], - foreignTables: [], - columns: [], - primaryKeys: [], - relationships: [], - functions: [], - types: [], -}; +describe("registry error mapping", () => { + it("keeps the registry's message, install hint included, for a missing toolchain", () => { + const hint = "Install the Dart SDK."; + const mapped = mapRegistryError( + "dart", + new ToolNotInstalledError({ + language: "dart", + tool: "dart", + installHint: hint, + message: `Generating dart types needs \`dart\`, which was not found on PATH. ${hint}`, + }), + ); + expect(mapped).toBeInstanceOf(GenTypesToolNotInstalledError); + expect(mapped.message).toBe( + `Generating dart types needs \`dart\`, which was not found on PATH. ${hint}`, + ); + }); -/** - * `tsconfig.types.json` type-checks this package against its published `dist/*.d.ts`, while Bun - * resolves its `bun` exports condition to `src/*.ts` at runtime. These assertions run against the - * Bun-resolved module, so a drift between the two views fails here rather than at generation time. - */ -describe("postgrest-typegen runtime contract", () => { - it("exposes the introspection and generation entry points the generator layer calls", () => { - expect(typeof introspect).toBe("function"); - expect(typeof sortGeneratorMetadata).toBe("function"); - expect(typeof generateTypescript).toBe("function"); - expect(typeof generateGo).toBe("function"); - expect(typeof generatePython).toBe("function"); - expect(typeof generateSwift).toBe("function"); + it("keeps the tool's stderr when it fails", () => { + const mapped = mapRegistryError( + "dart", + new ToolFailedError({ + language: "dart", + command: ["dart", "run"], + exitCode: 78, + stderr: "needs Dart 3.8", + message: "`dart run` exited with code 78.\nneeds Dart 3.8", + }), + ); + expect(mapped).toBeInstanceOf(GenTypesToolFailedError); + expect(mapped.message).toContain("needs Dart 3.8"); }); - it.effect("renders every supported language from metadata alone", () => - Effect.gen(function* () { - const metadata = sortGeneratorMetadata(emptyMetadata); + it("reports a rejected option and any other failure as a generation error", () => { + const invalid = mapRegistryError( + "swift", + new InvalidOptionError({ language: "swift", option: "x", message: "no option x" }), + ); + expect(invalid).toBeInstanceOf(GenTypesGenerationError); + expect(invalid.message).toBe("no option x"); + const other = mapRegistryError("go", new Error("boom")); + expect(other).toBeInstanceOf(GenTypesGenerationError); + expect(other.message).toBe("failed to generate go types: boom"); + }); +}); - expect( - yield* Effect.promise(() => - generateTypescript(metadata, { format: (code) => Promise.resolve(code) }), - ), - ).toContain("public"); - expect(generateGo(metadata)).toContain("package"); - expect(generatePython(metadata)).toContain("import"); - expect(generateSwift(metadata, { accessControl: "internal" })).toContain("import Supabase"); - }), - ); +describe("declaredOptions", () => { + it("forwards only the options the language declares", () => { + const swift = findLanguage("swift")!; + expect( + declaredOptions(swift, { + "swift-access-control": "public", + "detect-one-to-one-relationships": false, + unknown: true, + }), + ).toEqual({ "swift-access-control": "public" }); + expect( + declaredOptions(findLanguage("typescript")!, { + "swift-access-control": "public", + "detect-one-to-one-relationships": false, + }), + ).toEqual({ "detect-one-to-one-relationships": false }); + expect(declaredOptions(findLanguage("go")!, { "swift-access-control": "public" })).toEqual({}); + }); }); diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index addb2381f1..3885284cec 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,7 +1,7 @@ import type { LoadedCliConfig } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from "effect"; +import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { getDomain } from "tldts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -44,6 +44,11 @@ import { runWithPoolerFallback, } from "../../../command-internal/pooler-fallback.ts"; import type { GenTypesFlags } from "./types.command.ts"; +import { + GEN_TYPES_LANGUAGE_FLAG_NAMES, + GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, + languageOptionValues, +} from "./types.languages.ts"; import { GenTypesBranchCredentialsUnavailableError, GenTypesFlagUsageError, @@ -56,7 +61,7 @@ import { GenTypesUnexpectedStatusError, GenTypesWorkdirError, } from "./types.errors.ts"; -import { type GenTypesGenerationError, GenTypesGenerator } from "./types.generator.service.ts"; +import { type GenTypesGenerateError, GenTypesGenerator } from "./types.generator.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { @@ -109,25 +114,19 @@ function isPoolerHost(host: string, poolerHost: string): boolean { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; -type GenTypesMutexFlag = - | "local" - | "linked" - | "project-id" - | "db-url" - | "postgrest-v9-compat" - | "swift-access-control" - | "query-timeout"; - // Validation reports only the first violated group, in this listed order — e.g. `--db-url X // --postgrest-v9-compat --project-id Y` reports the postgrest group, not the // local/linked/project-id/db-url group. -const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray> = [ +const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray> = [ ["linked", "project-id", "postgrest-v9-compat"], ["linked", "project-id", "query-timeout"], - ["linked", "project-id", "swift-access-control"], + ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["linked", "project-id", name]), ["local", "linked", "project-id", "db-url"], ]; +const POSTGREST_V9_COMPAT_DEPRECATION_LINE = + "Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection."; + /** * Every value-taking flag `gen types` parses, telling `pflagArgvScan` which bare tokens * consume the next argv token as their value. Boolean flags (`--local`, `--linked`, @@ -139,7 +138,7 @@ const GEN_TYPES_SCAN_SPEC = { "project-id", "lang", "schema", - "swift-access-control", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "query-timeout", ...PERSISTENT_VALUE_FLAG_NAMES, ]), @@ -163,7 +162,7 @@ const LONG_FLAGS_WITH_VALUES = new Set([ "project-id", "lang", "schema", - "swift-access-control", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "query-timeout", "profile", "workdir", @@ -248,7 +247,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const schemas = flags.schema; const lang = flags.lang; - const swiftAccessControl = flags.swiftAccessControl; + const languageOptions = languageOptionValues(flags); const toRelativeConfigPath = (path: string) => relativeConfigPath(cliSettings.workdir, path); @@ -311,11 +310,21 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) * `toConnectError` classifies the IPv6-unreachable dial failure at the connection boundary and * exposes it via `DbConnectError.ipv6Unreachable`, so a `DbConnectError` no longer carries the * raw driver cause `isIPv6ConnectivityErrorCause` needs; fall back to it for any other error. + * An out-of-process tool runs after the connection succeeded, and its errors carry the tool's + * stderr in the message, which could name a host the tool itself failed to reach; they are + * never a database connectivity failure, so they never earn the pooler retry. */ - const classifyGenerateError = (error: DbConnectError | GenTypesGenerationError): boolean => - Predicate.isTagged(error, "DbConnectError") - ? (error.ipv6Unreachable ?? false) - : isIPv6ConnectivityErrorCause(error); + const classifyGenerateError = (error: DbConnectError | GenTypesGenerateError): boolean => { + switch (error._tag) { + case "DbConnectError": + return error.ipv6Unreachable ?? false; + case "GenTypesToolNotInstalledError": + case "GenTypesToolFailedError": + return false; + case "GenTypesGenerationError": + return isIPv6ConnectivityErrorCause(error); + } + }; const runGenerate = (input: { readonly conn: PgConnInput; @@ -340,8 +349,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) dnsResolver, lang, includedSchemas: input.includedSchemas, - detectOneToOneRelationships: input.detectOneToOneRelationships, - swiftAccessControl, + options: { + ...languageOptions, + "detect-one-to-one-relationships": input.detectOneToOneRelationships, + }, }); }), ); @@ -535,6 +546,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); } + if (occurrences.has("postgrest-v9-compat")) { + yield* output.raw(`${POSTGREST_V9_COMPAT_DEPRECATION_LINE}\n`, "stderr"); + } + // This guard runs before flag-group validation, so its error wins when both apply. Both // run after the telemetry context is installed, so every return here must stay inside the // `Effect.ensuring(telemetryState.flush)` below. @@ -559,14 +574,16 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // A flag counts as set once passed explicitly, regardless of value (`--linked=false` // still trips its group). `project-id`/`db-url` are read straight off parsed flags since // they have no boolean-vs-default ambiguity. - const changedMutexFlags: Record = { + const changedMutexFlags: Record = { local: occurrences.has("local"), linked: occurrences.has("linked"), "project-id": Option.isSome(flags.projectId), "db-url": Option.isSome(flags.dbUrl), "postgrest-v9-compat": occurrences.has("postgrest-v9-compat"), - "swift-access-control": occurrences.has("swift-access-control"), "query-timeout": occurrences.has("query-timeout"), + ...Object.fromEntries( + GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => [name, occurrences.has(name)]), + ), }; for (const group of GEN_TYPES_MUTEX_GROUPS) { const set = group.filter((flagName) => changedMutexFlags[flagName]); diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index dc9d030e0f..d494a07a5e 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -52,9 +52,11 @@ import { genTypes } from "./types.handler.ts"; import { localDbContainerId, parseQueryTimeoutMillis, rootCaBundle } from "./types.shared.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { + type GenTypesGenerateError, GenTypesGenerationError, GenTypesGenerator, type GenTypesGenerateInput, + GenTypesToolFailedError, } from "./types.generator.service.ts"; const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); @@ -113,7 +115,7 @@ function defaultFlags(overrides: Partial = {}): GenTypesFlags { projectId: Option.none(), lang: "typescript" as const, schema: [], - swiftAccessControl: "internal" as const, + "swift-access-control": Option.none(), postgrestV9Compat: false, queryTimeout: "15s", ...overrides, @@ -185,7 +187,7 @@ function mockGenTypesGenerator( readonly generate?: ( input: GenTypesGenerateInput, callIndex: number, - ) => Effect.Effect; + ) => Effect.Effect; readonly output?: string; } = {}, ) { @@ -209,7 +211,7 @@ function mockGenTypesGenerator( /** One `GenTypesGenerator.generate` outcome per attempt — models a failing then a retried call. */ function sequentialGenerator( - steps: ReadonlyArray<() => Effect.Effect>, + steps: ReadonlyArray<() => Effect.Effect>, ) { return mockGenTypesGenerator({ generate: (_input, index) => @@ -263,6 +265,18 @@ function nonIpv6Failure(lang = "go") { }); } +/** + * A tool failure whose stderr reads like a DNS miss. The message-text fallback of + * `isIPv6ConnectivityErrorCause` would classify it as IPv6-retryable, but the tool ran after the + * database connection had already succeeded. + */ +function toolFailureNamingAHost() { + return new GenTypesToolFailedError({ + message: + "failed to generate dart types: dart run supabase_typegen exited with code 1: Failed host lookup: 'pub.dev' (OS Error: No address associated with hostname, errno = 7)", + }); +} + /** * A single `container inspect` spawn — the only subprocess `gen types --local` still shells out * to (via `assertLocalDbRunning`) now that generation itself runs in-process. `dockerMissing` @@ -941,7 +955,11 @@ describe("gen types", () => { args: ["gen", "types", "--linked", "--swift-access-control", "public", "--lang", "swift"], }); const exit = yield* genTypes( - defaultFlags({ linked: true, lang: "swift", swiftAccessControl: "public" }), + defaultFlags({ + linked: true, + lang: "swift", + "swift-access-control": Option.some("public"), + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -971,7 +989,7 @@ describe("gen types", () => { defaultFlags({ projectId: Option.some(VALID_REF), lang: "swift", - swiftAccessControl: "public", + "swift-access-control": Option.some("public"), }), ).pipe(Effect.provide(layer), Effect.exit); @@ -1441,12 +1459,16 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), }); yield* genTypes( - defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + defaultFlags({ + lang: "go", + queryTimeout: "20s", + "swift-access-control": Option.some("public"), + }), ).pipe(Effect.provide(layer)); expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); const call = generator.calls[0]; - expect(call?.swiftAccessControl).toBe("public"); + expect(call?.options["swift-access-control"]).toBe("public"); expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); expect(call?.conn.connectTimeoutSeconds).toBe(20); }).pipe(Effect.provide(BunServices.layer)), @@ -1850,6 +1872,41 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("does not retry through the pooler when an out-of-process tool fails", () => + Effect.gen(function* () { + const generator = sequentialGenerator([() => Effect.fail(toolFailureNamingAHost())]); + const { layer, out, dbConfig } = yield* setup({ + args: ["gen", "types", "--lang", "dart", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "dart" }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("preserves the original generation error when pooler fallback resolution fails", () => Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); @@ -2188,7 +2245,7 @@ describe("gen types", () => { }); expect(call?.isLocal).toBe(true); expect(call?.includedSchemas).toEqual(["public", "custom"]); - expect(call?.detectOneToOneRelationships).toBe(true); + expect(call?.options["detect-one-to-one-relationships"]).toBe(true); expect(call?.conn.sslmode).toBeUndefined(); expect(call?.conn.sslrootcertInline).toBeUndefined(); expect(linkedProjectCache.cached).toBe(false); @@ -2277,7 +2334,7 @@ describe("gen types", () => { const { layer, generator } = yield* setup({ workdir }); yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); + expect(generator.calls[0]?.options["detect-one-to-one-relationships"]).toBe(false); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2301,7 +2358,7 @@ describe("gen types", () => { const { layer, generator } = yield* setup({ workdir }); yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(true); + expect(generator.calls[0]?.options["detect-one-to-one-relationships"]).toBe(true); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2349,11 +2406,15 @@ describe("gen types", () => { args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], }); yield* genTypes( - defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), + defaultFlags({ + local: true, + lang: "python", + "swift-access-control": Option.some("public"), + }), ).pipe(Effect.provide(layer)); expect(generator.calls[0]?.lang).toBe("python"); - expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + expect(generator.calls[0]?.options["swift-access-control"]).toBe("public"); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2774,7 +2835,7 @@ describe("gen types", () => { dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), lang: "swift", schema: ["public"], - swiftAccessControl: "public", + "swift-access-control": Option.some("public"), postgrestV9Compat: true, queryTimeout: "20s", }), @@ -2782,25 +2843,32 @@ describe("gen types", () => { const call = generator.calls[0]; expect(call?.lang).toBe("swift"); - expect(call?.swiftAccessControl).toBe("public"); - expect(call?.detectOneToOneRelationships).toBe(false); + expect(call?.options["swift-access-control"]).toBe("public"); + expect(call?.options["detect-one-to-one-relationships"]).toBe(false); expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); expect(call?.conn.connectTimeoutSeconds).toBe(20); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("allows --postgrest-v9-compat together with --db-url", () => - Effect.gen(function* () { - const { layer, generator } = yield* setup(); - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer)); + it.live( + "allows --postgrest-v9-compat together with --db-url and prints its deprecation line", + () => + Effect.gen(function* () { + const { layer, generator, out } = yield* setup({ + args: ["gen", "types", "--db-url", "postgresql://x", "--postgrest-v9-compat"], + }); + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + postgrestV9Compat: true, + }), + ).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), + expect(generator.calls[0]?.options["detect-one-to-one-relationships"]).toBe(false); + expect(out.stderrText).toContain( + "Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection.", + ); + }).pipe(Effect.provide(BunServices.layer)), ); it.live("allows legacy positional non-typescript when --lang is explicitly set", () => diff --git a/apps/cli/src/commands/gen/types/types.languages.ts b/apps/cli/src/commands/gen/types/types.languages.ts new file mode 100644 index 0000000000..77e45da8fd --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.languages.ts @@ -0,0 +1,138 @@ +import { languages, type OptionSpec, type OptionValue, type OptionValues } from "@supabase/typegen"; +import { Option } from "effect"; +import { Flag } from "effect/unstable/cli"; + +/** A user-facing registry option as one CLI flag, merged across the languages that declare it. */ +export interface LanguageFlagSpec { + readonly name: string; + readonly kind: OptionSpec["kind"]; + readonly help: string; + /** Every declaring language's choices; `undefined` for other kinds. */ + readonly choices?: ReadonlyArray; + /** Shown in help and docs only when every declaring language agrees; never sent. */ + readonly default?: OptionValue; +} + +export type GenTypesLanguageFlagValue = Option.Option; + +/** + * One flag per option name. The flag carries no default: the registry applies each language's + * own default to the values the user did not set. + */ +export const mergeUserOptions = ( + specs: ReadonlyArray, +): ReadonlyArray => { + const byName = new Map(); + for (const spec of specs) { + if (spec.audience !== "user") continue; + const existing = byName.get(spec.name); + if (existing === undefined) { + byName.set(spec.name, { + name: spec.name, + kind: spec.kind, + help: spec.help, + choices: spec.kind === "choice" ? [...spec.choices] : undefined, + default: spec.default, + }); + continue; + } + if (existing.kind !== spec.kind) { + throw new Error( + `@supabase/typegen declares --${spec.name} as both ${existing.kind} and ${spec.kind}`, + ); + } + byName.set(spec.name, { + ...existing, + choices: + existing.choices !== undefined && spec.kind === "choice" + ? [...new Set([...existing.choices, ...spec.choices])] + : existing.choices, + default: existing.default === spec.default ? existing.default : undefined, + }); + } + return [...byName.values()]; +}; + +const languageFlag = (spec: LanguageFlagSpec): Flag.Flag => { + const help = + spec.default === undefined ? spec.help : `${spec.help} (default ${String(spec.default)})`; + switch (spec.kind) { + case "boolean": + return Flag.boolean(spec.name).pipe(Flag.withDescription(help), Flag.optional); + case "choice": + return Flag.choice(spec.name, spec.choices ?? []).pipe( + Flag.withDescription(help), + Flag.optional, + ); + case "string": + return Flag.string(spec.name).pipe(Flag.withDescription(help), Flag.optional); + } +}; + +/** Throws when a registry option reuses a flag name the command or the CLI already defines. */ +export const languageFlagsFor = ( + specs: ReadonlyArray, + reservedFlagNames: Iterable, +): Readonly>> => { + const reserved = new Set(reservedFlagNames); + const collisions = specs.map((spec) => spec.name).filter((name) => reserved.has(name)); + if (collisions.length > 0) { + throw new Error( + `@supabase/typegen declares language flags that collide with CLI flags: ${collisions.join(", ")}`, + ); + } + return Object.fromEntries(specs.map((spec) => [spec.name, languageFlag(spec)])); +}; + +/** The values the user set, keyed by option name; unset flags are absent. */ +export const optionValuesFor = ( + specs: ReadonlyArray, + flags: Readonly>, +): OptionValues => { + const values: Record = {}; + for (const spec of specs) { + const value = flags[spec.name]; + if (!Option.isOption(value) || Option.isNone(value)) continue; + if (typeof value.value === "string" || typeof value.value === "boolean") { + values[spec.name] = value.value; + } + } + return values; +}; + +/** Documented defaults keyed the way `DOCS_DEFAULT_OVERRIDES` expects. */ +export const flagDefaultsFor = ( + specs: ReadonlyArray, +): Readonly> => + Object.fromEntries( + specs.flatMap((spec) => + spec.default === undefined ? [] : [[`supabase-gen-types ${spec.name}`, String(spec.default)]], + ), + ); + +export const GEN_TYPES_LANGUAGES: ReadonlyArray = languages.map( + (language) => language.name, +); + +const GEN_TYPES_LANGUAGE_OPTIONS = mergeUserOptions( + languages.flatMap((language) => language.options), +); + +export const GEN_TYPES_LANGUAGE_FLAG_NAMES: ReadonlyArray = GEN_TYPES_LANGUAGE_OPTIONS.map( + (option) => option.name, +); + +/** Language flags that consume the next argv token, for the pflag-style scans in the handler. */ +export const GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES: ReadonlyArray = + GEN_TYPES_LANGUAGE_OPTIONS.filter((option) => option.kind !== "boolean").map( + (option) => option.name, + ); + +export const genTypesLanguageFlags = (reservedFlagNames: Iterable) => + languageFlagsFor(GEN_TYPES_LANGUAGE_OPTIONS, reservedFlagNames); + +export const genTypesLanguageFlagDefaults = (): Readonly> => + flagDefaultsFor(GEN_TYPES_LANGUAGE_OPTIONS); + +export const languageOptionValues = (flags: Readonly>): OptionValues => + optionValuesFor(GEN_TYPES_LANGUAGE_OPTIONS, flags); diff --git a/apps/cli/src/commands/gen/types/types.languages.unit.test.ts b/apps/cli/src/commands/gen/types/types.languages.unit.test.ts new file mode 100644 index 0000000000..2018e97091 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.languages.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { OptionSpec } from "@supabase/typegen"; +import { Option } from "effect"; +import { + flagDefaultsFor, + languageFlagsFor, + mergeUserOptions, + optionValuesFor, +} from "./types.languages.ts"; + +const level = (choices: ReadonlyArray, fallback: string): OptionSpec => ({ + name: "level", + audience: "user", + kind: "choice", + choices, + default: fallback, + help: "Access level.", +}); + +describe("mergeUserOptions", () => { + it("skips consumer options and keeps one flag per name", () => { + const merged = mergeUserOptions([ + level(["a", "b"], "a"), + { name: "version", audience: "consumer", kind: "string", help: "" }, + level(["b", "c"], "a"), + ]); + expect(merged).toEqual([ + { + name: "level", + kind: "choice", + help: "Access level.", + choices: ["a", "b", "c"], + default: "a", + }, + ]); + }); + + it("drops the default when the declaring languages disagree", () => { + const [merged] = mergeUserOptions([level(["a", "b"], "a"), level(["a", "b"], "b")]); + expect(merged?.default).toBeUndefined(); + }); + + it("refuses one name declared with two kinds", () => { + expect(() => + mergeUserOptions([ + level(["a"], "a"), + { name: "level", audience: "user", kind: "boolean", default: false, help: "" }, + ]), + ).toThrow(/--level as both choice and boolean/); + }); +}); + +describe("languageFlagsFor", () => { + const specs = mergeUserOptions([level(["a", "b"], "a")]); + + it("refuses a registry flag that reuses a reserved name", () => { + expect(() => languageFlagsFor(specs, ["lang", "level"])).toThrow( + /collide with CLI flags: level/, + ); + expect(Object.keys(languageFlagsFor(specs, ["lang"]))).toEqual(["level"]); + }); +}); + +describe("optionValuesFor", () => { + const specs = mergeUserOptions([ + level(["a", "b"], "a"), + { name: "verbose", audience: "user", kind: "boolean", default: false, help: "" }, + ]); + + it("forwards only the flags the user set, so each language applies its own default", () => { + expect(optionValuesFor(specs, { level: Option.some("b"), verbose: Option.none() })).toEqual({ + level: "b", + }); + expect(optionValuesFor(specs, { level: Option.none(), verbose: Option.some(true) })).toEqual({ + verbose: true, + }); + expect(optionValuesFor(specs, { lang: "swift" })).toEqual({}); + }); +}); + +describe("flagDefaultsFor", () => { + it("documents a default only when it is unambiguous", () => { + expect(flagDefaultsFor(mergeUserOptions([level(["a"], "a")]))).toEqual({ + "supabase-gen-types level": "a", + }); + expect(flagDefaultsFor(mergeUserOptions([level(["a"], "a"), level(["b"], "b")]))).toEqual({}); + }); +}); diff --git a/apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts b/apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts new file mode 100644 index 0000000000..991dedfb47 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts @@ -0,0 +1,42 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { makeTypegenHost } from "./types.typegen-host.ts"; + +describe.skipIf(process.platform === "win32")("makeTypegenHost with the real spawner", () => { + const spawnInto = (tool: string, stdin: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const host = makeTypegenHost({ + cwd: process.cwd(), + env: {}, + platform: process.platform, + spawner, + runPromise, + }); + if (host.spawn === undefined) throw new Error("the typegen host must supply spawn"); + return yield* Effect.promise(() => + host.spawn!({ command: "sh", args: ["-c", tool], cwd: process.cwd(), env: {}, stdin }), + ); + }).pipe(Effect.provide(BunServices.layer)); + + // A write this small completes before the tool exits, so the pipe's EPIPE arrives after the + // sink has stopped listening; only the patched stdin error listener keeps it from crashing + // the process (seen on Linux, never on macOS). + it.effect("survives a tool that exits before reading a small stdin", () => + Effect.gen(function* () { + const result = yield* spawnInto("exit 3", "x".repeat(1024)); + expect(result.exitCode).toBe(3); + }), + ); + + // Far more than a pipe buffer holds, so the write itself meets the closed pipe for certain. + it.effect("survives a tool that exits before reading a large stdin", () => + Effect.gen(function* () { + const result = yield* spawnInto("exit 3", "x".repeat(4 * 1024 * 1024)); + expect(result.exitCode).toBe(3); + }), + ); +}); diff --git a/apps/cli/src/commands/gen/types/types.typegen-host.ts b/apps/cli/src/commands/gen/types/types.typegen-host.ts new file mode 100644 index 0000000000..2c7cf0d2ef --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.typegen-host.ts @@ -0,0 +1,121 @@ +import { + type Host, + isWindowsScript, + quoteForCmd, + resolveWindowsCommand, + type SpawnRequest, + type SpawnResult, +} from "@supabase/typegen"; +import { Effect, Predicate, Stream } from "effect"; +import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process"; + +import { collectText } from "../../../command-internal/container-cli.ts"; + +export interface TypegenHostOptions { + /** Where out-of-process tools run: the directory the command was invoked from. */ + readonly cwd: string; + /** What the registry's Windows lookup reads; the child inherits the full environment. */ + readonly env: Readonly>; + readonly platform: NodeJS.Platform; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + /** `Effect.runPromiseWith(context)` from the generator fiber, so it owns the spawned tool. */ + readonly runPromise: ( + effect: Effect.Effect, + options?: { readonly signal?: AbortSignal | undefined }, + ) => Promise; +} + +type TypegenSpawnOutcome = + | { readonly _tag: "Exited"; readonly result: SpawnResult } + | { readonly _tag: "NotFound" } + | { readonly _tag: "Failed"; readonly error: unknown }; + +const isNotFound = (error: unknown): boolean => + Predicate.hasProperty(error, "reason") && Predicate.isTagged(error.reason, "NotFound"); + +const spawnForTypegen = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + request: SpawnRequest, + platform: NodeJS.Platform, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + let command = request.command; + let args: ReadonlyArray = request.args; + let shell = false; + if (platform === "win32") { + // `spawn` cannot start the `.bat` Flutter ships `dart` as without a shell. + const resolved = resolveWindowsCommand(request.command, request.env); + if (resolved === undefined) return { _tag: "NotFound" } as const; + shell = isWindowsScript(resolved); + command = shell ? quoteForCmd(resolved) : resolved; + if (shell) args = request.args.map(quoteForCmd); + } + const child = yield* spawner.spawn( + ChildProcess.make(command, [...args], { + cwd: request.cwd, + env: request.env, + extendEnv: true, + shell, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ); + // Written here rather than handed to the spawner, so a tool that exits before reading its + // input fails the write in this fiber, where it is expected, instead of in a forked one. + const feedStdin = Stream.run( + Stream.make(new TextEncoder().encode(request.stdin)), + child.stdin, + ).pipe(Effect.ignore); + const [, stdout, stderr, exitCode] = yield* Effect.all( + [ + feedStdin, + collectText(child.stdout), + collectText(child.stderr), + // A signal-ended process fails `exitCode`; the registry's contract wants `null`. + child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed((): number | null => null), + ), + ], + { concurrency: "unbounded" }, + ); + return { _tag: "Exited", result: { exitCode, stdout, stderr } } as const; + }), + ).pipe( + Effect.catch((error) => + Effect.succeed( + isNotFound(error) ? { _tag: "NotFound" } : { _tag: "Failed", error }, + ), + ), + ); + +const commandNotFound = (command: string): Error => + Object.assign(new Error(`spawn ${command} ENOENT`), { + code: "ENOENT", + syscall: `spawn ${command}`, + path: command, + }); + +/** The registry `Host` for `gen types`; TypeScript is handed back unformatted, `oxfmt` stays out. */ +export const makeTypegenHost = (options: TypegenHostOptions): Host => ({ + cwd: options.cwd, + env: options.env, + spawn: (request) => + options + .runPromise(spawnForTypegen(options.spawner, request, options.platform), { + signal: request.signal, + }) + .then((outcome) => { + switch (outcome._tag) { + case "Exited": + return outcome.result; + case "NotFound": + throw commandNotFound(request.command); + case "Failed": + throw outcome.error; + } + }), + format: (code) => Promise.resolve(code), +}); diff --git a/apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts b/apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts new file mode 100644 index 0000000000..8be1e919e3 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts @@ -0,0 +1,196 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import type { SpawnRequest, SpawnResult } from "@supabase/typegen"; +import { Data, Effect, FileSystem, Path, PlatformError, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { makeTypegenHost } from "./types.typegen-host.ts"; + +interface SpawnCall { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string | undefined; + readonly env: Readonly> | undefined; + readonly extendEnv: boolean | undefined; + readonly shell: boolean | string | undefined; + stdin: string; +} + +/** Records what `spawnForTypegen` asks for and answers like a finished process. */ +function fakeSpawner( + opts: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + readonly notFound?: boolean; + } = {}, +) { + const calls: Array = []; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("unexpected command shape"); + } + const call: SpawnCall = { + command: command.command, + args: command.args, + cwd: command.options.cwd, + env: command.options.env, + extendEnv: command.options.extendEnv, + shell: command.options.shell, + stdin: "", + }; + calls.push(call); + if (opts.notFound === true) { + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${command.command} not found`, + }); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4242), + stdout: Stream.make(encoder.encode(opts.stdout ?? "")), + stderr: Stream.make(encoder.encode(opts.stderr ?? "")), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)), + isRunning: Effect.succeed(false), + stdin: Sink.forEach((chunk: Uint8Array) => + Effect.sync(() => { + call.stdin += decoder.decode(chunk, { stream: true }); + }), + ), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, calls }; +} + +const request = (overrides: Partial = {}): SpawnRequest => ({ + command: "dart", + args: ["run", "supabase_typegen", "--output", "-"], + cwd: "/projects/app", + env: { PATH: "/usr/bin" }, + stdin: '{"version":1}', + ...overrides, +}); + +const host = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + platform: NodeJS.Platform = "darwin", + env: Readonly> = { PATH: "/usr/bin" }, +) => + makeTypegenHost({ + cwd: "/projects/app", + env, + platform, + spawner, + runPromise: (effect, options) => Effect.runPromise(effect, options), + }); + +describe("makeTypegenHost", () => { + const spawnOf = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + platform: NodeJS.Platform = "darwin", + env: Readonly> = { PATH: "/usr/bin" }, + ) => { + const { spawn } = host(spawner, platform, env); + if (spawn === undefined) throw new Error("the typegen host must supply spawn"); + return (req: SpawnRequest) => Effect.promise(() => spawn(req)); + }; + + class SpawnRejected extends Data.TaggedError("SpawnRejected")<{ readonly reason: unknown }> {} + + /** The rejection of a spawn, for the contract cases where the promise must fail. */ + const rejectionOf = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + platform: NodeJS.Platform, + env: Readonly>, + req: SpawnRequest, + ) => { + const { spawn } = host(spawner, platform, env); + if (spawn === undefined) throw new Error("the typegen host must supply spawn"); + return Effect.tryPromise({ + try: () => spawn(req), + catch: (reason) => new SpawnRejected({ reason }), + }).pipe(Effect.flip); + }; + + it.effect("returns what the tool wrote and feeds it the document on stdin", () => + Effect.gen(function* () { + const { spawner, calls } = fakeSpawner({ + stdout: "class Tickets {}\n", + stderr: "summary\n", + }); + const result: SpawnResult = yield* spawnOf(spawner)(request()); + + expect(result).toEqual({ exitCode: 0, stdout: "class Tickets {}\n", stderr: "summary\n" }); + expect(calls[0]?.stdin).toBe('{"version":1}'); + }), + ); + + it.effect("reports a non-zero exit as a result rather than a failure", () => + Effect.gen(function* () { + const { spawner } = fakeSpawner({ exitCode: 65, stderr: "Could not parse the document\n" }); + const result = yield* spawnOf(spawner)(request()); + expect(result.exitCode).toBe(65); + expect(result.stderr).toBe("Could not parse the document\n"); + }), + ); + + it.effect("rejects a missing executable with ENOENT, as the registry's Host contract asks", () => + Effect.gen(function* () { + const { spawner } = fakeSpawner({ notFound: true }); + const error = yield* rejectionOf(spawner, "darwin", { PATH: "/usr/bin" }, request()); + expect(error.reason).toMatchObject({ code: "ENOENT" }); + }), + ); + + it.effect("hands TypeScript back unformatted", () => + Effect.gen(function* () { + const { spawner } = fakeSpawner(); + const { format } = host(spawner); + if (format === undefined) throw new Error("the typegen host must supply format"); + const code = "export type Database = {}"; + expect(yield* Effect.promise(() => format(code, "output.ts"))).toBe(code); + }), + ); + + it.effect("on Windows, rejects with ENOENT without spawning when PATH holds no candidate", () => + Effect.gen(function* () { + const { spawner, calls } = fakeSpawner(); + const env = { PATH: "C:\\nowhere", PATHEXT: ".EXE;.BAT" }; + const error = yield* rejectionOf(spawner, "win32", env, request({ env })); + expect(error.reason).toMatchObject({ code: "ENOENT" }); + expect(calls).toEqual([]); + }), + ); + + // The registry's lookup joins Windows paths, which only exist on a Windows filesystem. + describe.skipIf(process.platform !== "win32")("on a Windows filesystem", () => { + it.effect("runs a .bat found through PATH and PATHEXT through the command interpreter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "typegen-host-" }), + ); + const flutter = path.join(dir, "flutter"); + yield* fs.makeDirectory(flutter); + yield* fs.writeFileString(path.join(flutter, "dart.BAT"), "@echo off\r\n"); + + const { spawner, calls } = fakeSpawner({ stdout: "ok" }); + const env = { PATH: flutter, PATHEXT: ".EXE;.BAT" }; + yield* spawnOf(spawner, "win32", env)(request({ env })); + expect(calls[0]?.command).toBe(path.join(flutter, "dart.BAT")); + expect(calls[0]?.shell).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); +}); diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 760bf0c1ce..edd7bc02d4 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -1,4 +1,5 @@ import type { DocsFlag } from "./docs-spec.ts"; +import { genTypesLanguageFlagDefaults } from "../commands/gen/types/types.languages.ts"; /** * Static data for the docs spec generator — information the Effect command tree cannot @@ -121,6 +122,7 @@ export const DOCS_EXCLUDED: ReadonlySet = new Set([ * flags add entries by hand. */ export const DOCS_DEFAULT_OVERRIDES: Readonly> = { + ...genTypesLanguageFlagDefaults(), "supabase agent": "auto", "supabase dns-resolver": "native", "supabase output": "pretty", @@ -150,7 +152,6 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-gen-signing-key algorithm": "ES256", "supabase-gen-types lang": "typescript", "supabase-gen-types query-timeout": "15s", - "supabase-gen-types swift-access-control": "internal", "supabase-inspect-db-bloat linked": "true", "supabase-inspect-db-blocking linked": "true", "supabase-inspect-db-calls linked": "true", diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 821039993d..017742dd1f 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -292,6 +292,8 @@ GenTypesMissingProjectConfigError GenTypesNetworkError GenTypesNetworkIdUnsupportedError GenTypesParseConfigError +GenTypesToolFailedError +GenTypesToolNotInstalledError GenTypesUnexpectedStatusError GenTypesWorkdirError GoChildExitError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 45c0526a22..552e6bcc25 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -387,6 +387,20 @@ export const actionability = { has_suggestion: true, suggestion_type: CliSuggestionType.RerunDebug, }, + /** A toolchain the command shells out to is missing; the remedy varies per tool. */ + toolNotInstalled: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + /** A tool the command shells out to exited unsuccessfully; its stderr is in the message. */ + toolFailed: { + error_kind: CliErrorKind.Unknown, + error_category: CliErrorCategory.Unknown, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, apiStatus: { error_kind: CliErrorKind.ExternalService, error_category: CliErrorCategory.ApiStatus, diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json index ac42ee7bfb..b3f71b369e 100644 --- a/apps/cli/tsconfig.types.json +++ b/apps/cli/tsconfig.types.json @@ -1,16 +1,13 @@ { - // Type-check-only overlay. `@supabase/postgrest-typegen`'s `bun` exports condition points at - // its unbuilt `src/*.ts`, which does not satisfy this workspace's stricter compiler options; - // its published `dist/*.d.ts` describes the same API and does. The pin cannot live in - // `tsconfig.json` because Bun honours `paths` at runtime too, and would then load a - // declaration file instead of the implementation. `types.generator.unit.test.ts` - // exercises the Bun-resolved runtime API so the two views cannot drift unnoticed. + // Type-check-only overlay for the `@supabase/pg-topo` pin from `tsconfig.json` (see the note + // there). The pin cannot live in `tsconfig.json` alone for type-checking purposes because Bun + // honours `paths` at runtime too, and would then load a declaration file instead of the + // implementation. "extends": "./tsconfig.json", "compilerOptions": { // `paths` replaces the base map wholesale, so the inherited pin is repeated here. "paths": { - "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"], - "@supabase/postgrest-typegen": ["./node_modules/@supabase/postgrest-typegen/dist/index.d.ts"] + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] } } } diff --git a/patches/@effect__platform-node-shared@4.0.0-rc.112.patch b/patches/@effect__platform-node-shared@4.0.0-rc.112.patch new file mode 100644 index 0000000000..9ad2c171a9 --- /dev/null +++ b/patches/@effect__platform-node-shared@4.0.0-rc.112.patch @@ -0,0 +1,15 @@ +diff --git a/dist/NodeChildProcessSpawner.js b/dist/NodeChildProcessSpawner.js +index aa8abbe375f40350f9a5a12f36143d5d85802485..5a86ce881e6a5aa1358059dc0f10ec0934ef164d 100644 +--- a/dist/NodeChildProcessSpawner.js ++++ b/dist/NodeChildProcessSpawner.js +@@ -196,6 +196,10 @@ const make = /*#__PURE__*/Effect.gen(function* () { + // sink that will attached to the process handle + let sink = Sink.drain; + if (Predicate.isNotNull(childProcess.stdin)) { ++ // A child that exits before reading its input fails the final flush of `end()` with ++ // EPIPE after the sink's own listener is gone; without a listener Node raises it as an ++ // uncaught exception. The write loop still reports write failures through `onError`. ++ childProcess.stdin.on("error", () => {}); + sink = NodeSink.fromWritable({ + evaluate: () => childProcess.stdin, + onError: error => toPlatformError("fromWritable(stdin)", toError(error), command), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0474674a2..c3d2f8b7a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,7 @@ overrides: packageExtensionsChecksum: sha256-gAA6Mc6Jn3jUpogzVWO9hE5trVRioaB8peQBgDbyG6I= patchedDependencies: + '@effect/platform-node-shared@4.0.0-rc.112': ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128 '@effect/vitest@4.0.0-rc.112': fbd126f7e68e041231312d90b8c02f705f12b66c2cb2d6fc246f5c12a4ed9787 '@libpg-query/parser@17.6.10': ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3 @@ -335,15 +336,15 @@ importers: '@supabase/pg-topo': specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6(supports-color@7.2.0) - '@supabase/postgrest-typegen': - specifier: 0.2.2 - version: 0.2.2 '@supabase/stack': specifier: workspace:* version: link:../../packages/stack '@supabase/supabase-js': specifier: 'catalog:' version: 2.112.4 + '@supabase/typegen': + specifier: 0.2.1 + version: 0.2.1(oxfmt@0.66.0) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 @@ -2908,9 +2909,14 @@ packages: resolution: {integrity: sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==} engines: {node: '>=22.0.0'} - '@supabase/postgrest-typegen@0.2.2': - resolution: {integrity: sha512-TKh+GgakrkMlLahWtvwa6WWzbRzlK4tBExNfgZbnB0V5yMl51RIOvrgITQOacoJR3S/ZZCNbpvkRgHrhxSWsBg==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-typegen@0.3.1': + resolution: {integrity: sha512-FxgP+13VCH0ho3LKAhFHA6+vPk3Rbw54eZmGcDbFUcFzhjigSp8HBrgMJHVcNwjRrHpmItL0SvXAeAkOYxO7hQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + oxfmt: 0.66.0 + peerDependenciesMeta: + oxfmt: + optional: true '@supabase/realtime-js@2.112.4': resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==} @@ -2929,6 +2935,15 @@ packages: '@opentelemetry/api': optional: true + '@supabase/typegen@0.2.1': + resolution: {integrity: sha512-7M86EUD/zp9e3arbcBwXAPlUYowKEfhXXx5lgizjAqXzJeN4LZX8jeN8v+EYBWn3c8O1xXpl+HVG08V1MFLflQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + oxfmt: 0.66.0 + peerDependenciesMeta: + oxfmt: + optional: true + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -7110,13 +7125,13 @@ snapshots: '@effect/platform-bun@4.0.0-rc.112(effect@4.0.0-rc.112)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + '@effect/platform-node-shared': 4.0.0-rc.112(patch_hash=ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128)(effect@4.0.0-rc.112) effect: 4.0.0-rc.112 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112)': + '@effect/platform-node-shared@4.0.0-rc.112(patch_hash=ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128)(effect@4.0.0-rc.112)': dependencies: '@types/ws': 8.18.1 effect: 4.0.0-rc.112 @@ -7127,7 +7142,7 @@ snapshots: '@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + '@effect/platform-node-shared': 4.0.0-rc.112(patch_hash=ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128)(effect@4.0.0-rc.112) effect: 4.0.0-rc.112 mime: 4.1.0 redis: 6.2.1 @@ -8679,13 +8694,11 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/postgrest-typegen@0.2.2': + '@supabase/postgrest-typegen@0.3.1(oxfmt@0.66.0)': dependencies: arktype: 2.2.3 + optionalDependencies: oxfmt: 0.66.0 - transitivePeerDependencies: - - svelte - - vite-plus '@supabase/realtime-js@2.112.4': dependencies: @@ -8705,6 +8718,12 @@ snapshots: '@supabase/realtime-js': 2.112.4 '@supabase/storage-js': 2.112.4 + '@supabase/typegen@0.2.1(oxfmt@0.66.0)': + dependencies: + '@supabase/postgrest-typegen': 0.3.1(oxfmt@0.66.0) + optionalDependencies: + oxfmt: 0.66.0 + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -11567,6 +11586,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.66.0 '@oxfmt/binding-win32-ia32-msvc': 0.66.0 '@oxfmt/binding-win32-x64-msvc': 0.66.0 + optional: true oxlint-tsgolint@7.0.2001: optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bf029b2f75..3d232bd6b0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -110,7 +110,8 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-rc.112" - "@supabase/pg-delta@1.0.0-alpha.52" - "@supabase/pg-topo@1.0.0-alpha.6" - - "@supabase/postgrest-typegen@0.2.2" + - "@supabase/postgrest-typegen@0.3.1" + - "@supabase/typegen@0.2.1" - "@types/bun@1.4.0" - "bun-types@1.4.0" - "effect@4.0.0-rc.112" @@ -132,6 +133,7 @@ supportedArchitectures: - win32 patchedDependencies: + "@effect/platform-node-shared@4.0.0-rc.112": patches/@effect__platform-node-shared@4.0.0-rc.112.patch "@effect/vitest@4.0.0-rc.112": patches/@effect__vitest@4.0.0-rc.112.patch "@libpg-query/parser@17.6.10": patches/@libpg-query__parser@17.6.10.patch diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index 6f1db04923..6969c2bbff 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -16,7 +16,6 @@ import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { oxfmtStubPlugin } from "../../apps/cli/scripts/bundle-externals.ts"; import { compileOptions } from "../../apps/cli/scripts/compile-options.ts"; const PORT = 4873; @@ -188,7 +187,6 @@ async function main() { entrypoints: [entrypoint], compile: { target: platform.bunTarget, outfile: bunBinary }, ...compileOptions, - plugins: [oxfmtStubPlugin], }); for (const log of buildResult.logs) { console.warn(log); From dfc52e6ac7b67918bdc38e3b986e3b0a1568eb6f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 13:09:42 +0000 Subject: [PATCH 14/71] feat(stack): lease owners with a lock and bind session stacks to creators (#6838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The owner's exclusivity came from binding a sticky control port. - A foreign listener on that port wedged the stack, including `destroy`. - The owner never noticed its creator dying, so throwaway stacks (CLI schema shadows, test stacks) leaked the owner, containers and port claims after a SIGKILL, crash or OOM. - Nothing checked that a client and a running owner came from compatible releases. - Owner output was discarded. ## Change - Each owner holds a per-stack SQLite lease for its lifetime. It publishes an ephemeral loopback endpoint and a per-owner bearer secret in `owner.json`, with mode 0600. - Liveness is a no-wait lease probe, so dead stacks cost no network timeouts. - The lease is re-validated against the path it locked. - The handshake compares a release derived from the stack sources and the Effect version. Builds and source runs compute the same value. `stop` and `destroy` go through a release-stable endpoint. - `lifetime: "session"` binds a stack to its creator through stdin; the owner destroys the stack when the creator exits. CLI schema shadows use it. - The owner sweeps dead owners' containers and session stacks in the same state root, holding each lease while it does. - Owner output goes to `owner.log`, and start failures include its tail. - Clients cache the owner connection per handle and drop it only when the owner is gone. Calls release their borrows in their own scope, and waiting for an owner can be interrupted. - Stack errors carry `owner-unavailable` or `release-mismatch`, so the CLI only treats a missing owner as "not running". - The owner exits explicitly once shutdown has flushed its output. - #6825's guarantees are kept in the new model: - The owner registers detached stacks under its lease, as it already did session stacks, and removes the registration if its startup fails. A failed first launch therefore leaves nothing behind. - When no owner can start because the container engine is unavailable, `destroy` removes the stack offline while holding its lease, and reports the skipped runtime cleanup. Stacks registered by develop builds from before this PR don't decode with it, because persisted stack state has no migrations and the package is unreleased. Destroy them with the build that created them, or remove `~/.supabase/stacks/` and the containers labelled `com.supabase.stack=`. Known gap: if an owner is SIGKILLed right after registering a stack, its registration is left behind. ## Stack Part 5 of 8 of the stack package simplification, based on #6837. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators ← this PR 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/scripts/build-binary.ts | 3 +- apps/cli/scripts/build.ts | 3 +- apps/cli/scripts/compile-options.ts | 14 + apps/cli/src/command-internal/stack-api.ts | 8 +- .../command-internal/stack-local-database.ts | 9 +- apps/cli/src/command-internal/stack-shadow.ts | 11 +- .../cli/src/command-internal/stack-storage.ts | 2 +- .../commands/db/dump/dump.integration.test.ts | 1 + .../db/reset/reset.integration.test.ts | 82 ++- .../generate/generate.integration.test.ts | 1 + .../declarative/sync/sync.integration.test.ts | 1 + .../commands/db/shared/pgdelta.seam.layer.ts | 1 + .../db/start/start.integration.test.ts | 1 + .../stack/stack.shared.integration.test.ts | 1 + .../experimental/stack/start/SIDE_EFFECTS.md | 6 +- .../stack/status/status.integration.test.ts | 2 + .../experimental/stack/stop/stop.handler.ts | 1 + .../stack/stop/stop.integration.test.ts | 8 +- .../serve/serve.stack.integration.test.ts | 1 + apps/cli/tests/helpers/stack-cleanup.ts | 2 +- apps/cli/tests/helpers/storage.ts | 1 + packages/stack/ARCHITECTURE.md | 39 +- packages/stack/README.md | 25 +- packages/stack/package.json | 1 + .../stack/src/HostProcess.integration.test.ts | 313 +++++--- packages/stack/src/HostProcess.ts | 674 +++++++++++++----- .../stack/src/Network.integration.test.ts | 1 + packages/stack/src/Owner.integration.test.ts | 1 + packages/stack/src/Ports.integration.test.ts | 8 +- packages/stack/src/Ports.ts | 5 +- packages/stack/src/Rpc.ts | 6 +- ...ost.container-shutdown.integration.test.ts | 50 +- ....container-sweep-retry.integration.test.ts | 34 +- .../stack/src/StackHost.integration.test.ts | 111 +-- packages/stack/src/StackHost.ts | 213 +++++- packages/stack/src/State.integration.test.ts | 86 +++ .../src/State.process.integration.test.ts | 1 + packages/stack/src/State.ts | 367 ++++++++-- .../src/State.windows.integration.test.ts | 1 + packages/stack/src/Sweep.integration.test.ts | 210 ++++++ packages/stack/src/Sweep.ts | 79 ++ .../Supabase.native.integration.test.ts | 1 + packages/stack/src/effect.integration.test.ts | 436 ++++++++++- packages/stack/src/effect.ts | 579 ++++++++++----- packages/stack/src/index.ts | 23 +- .../src/internal/dispatch.integration.test.ts | 1 + packages/stack/src/internal/dispatch.ts | 10 +- packages/stack/src/internal/host-process.ts | 104 +-- .../src/internal/release.integration.test.ts | 22 + packages/stack/src/internal/release.ts | 50 ++ .../stack/src/lifetime.integration.test.ts | 224 ++++++ .../src/services/Rest.integration.test.ts | 1 + .../stack/src/shutdown.integration.test.ts | 3 +- .../stack/tests/compiled-dispatch-fixture.ts | 28 +- packages/stack/tests/failing-owner-fixture.ts | 24 + packages/stack/tests/host-process-fixture.ts | 176 ++--- packages/stack/tests/lease-wait-fixture.ts | 34 + .../stack/tests/lingering-owner-fixture.ts | 6 + packages/stack/tests/owner.ts | 63 +- packages/stack/tests/release-owner-fixture.ts | 6 + .../stack/tests/session-client-fixture.ts | 35 + tools/release/local-release.ts | 3 +- turbo.json | 4 +- 63 files changed, 3344 insertions(+), 873 deletions(-) create mode 100644 packages/stack/src/Sweep.integration.test.ts create mode 100644 packages/stack/src/Sweep.ts create mode 100644 packages/stack/src/internal/release.integration.test.ts create mode 100644 packages/stack/src/internal/release.ts create mode 100644 packages/stack/src/lifetime.integration.test.ts create mode 100644 packages/stack/tests/failing-owner-fixture.ts create mode 100644 packages/stack/tests/lease-wait-fixture.ts create mode 100644 packages/stack/tests/lingering-owner-fixture.ts create mode 100644 packages/stack/tests/release-owner-fixture.ts create mode 100644 packages/stack/tests/session-client-fixture.ts diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index d2e9a794f7..d37cc6dc8e 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,7 +1,7 @@ import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; import { Effect } from "effect"; -import { compileOptions } from "./compile-options.ts"; +import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; /** * Compiles the CLI to a standalone binary, run via `pnpm build:binary`. Embeds the pre-bundled @@ -24,6 +24,7 @@ const result = await Bun.build({ ...compileOptions, define: { SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), + ...(await stackReleaseDefine()), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( await Effect.runPromise(bundleStackFunctionsServeMainTemplate()), diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 8d5589e61e..c3173519ea 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -7,7 +7,7 @@ import { parseArgs } from "node:util"; import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; -import { compileOptions } from "./compile-options.ts"; +import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; const MUSL_TARGETS = [ @@ -90,6 +90,7 @@ const entrypoint = path.join(root, "apps/cli/src/main.ts"); const distDir = path.join(root, "dist"); const goSource = path.resolve(root, "apps/cli-go"); const buildDefines = { + ...(await stackReleaseDefine()), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( await Effect.runPromise(bundleStackFunctionsServeMainTemplate()), diff --git a/apps/cli/scripts/compile-options.ts b/apps/cli/scripts/compile-options.ts index 9f73a93e3d..0960516c5f 100644 --- a/apps/cli/scripts/compile-options.ts +++ b/apps/cli/scripts/compile-options.ts @@ -1,3 +1,7 @@ +import { BunServices } from "@effect/platform-bun"; +import { stackSourceDigest } from "@supabase/stack/internal/release"; +import { Effect } from "effect"; + /** * Shared compile options keep release, local, and E2E builds exercising the shipped binary * compilation behavior. `bytecodeDepth` is accepted by Bun 1.4.1 but is not yet in bun-types. @@ -11,3 +15,13 @@ export const compileOptions = { // TypeScript formatter; `gen types` supplies its own, so the binary ships without it. external: ["oxfmt"], }; + +/** + * Embeds the stack release of the sources being compiled, so the binary drives exactly the owners + * started from the same stack sources, whether compiled or run from source. + */ +export const stackReleaseDefine = async () => ({ + SUPABASE_STACK_BUILD_ID: JSON.stringify( + await Effect.runPromise(stackSourceDigest.pipe(Effect.provide(BunServices.layer))), + ), +}); diff --git a/apps/cli/src/command-internal/stack-api.ts b/apps/cli/src/command-internal/stack-api.ts index bc6221b4d1..db8975ebd3 100644 --- a/apps/cli/src/command-internal/stack-api.ts +++ b/apps/cli/src/command-internal/stack-api.ts @@ -1,4 +1,4 @@ -import { Context, Crypto, Effect, FileSystem, Layer, Path } from "effect"; +import { Context, Crypto, Effect, FileSystem, Layer, Path, Scope } from "effect"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -17,12 +17,12 @@ type StackError = Effect.Error>; type IdentityResult = Effect.Success>; type IdentityError = Effect.Error>; -/** Operations used by the CLI stack boundary. */ +/** Operations used by the CLI stack boundary; a handle lasts until its scope closes. */ export class StackApi extends Context.Service< StackApi, { - readonly create: (options: CreateOptions) => Effect.Effect; - readonly open: (options: OpenOptions) => Effect.Effect; + readonly create: (options: CreateOptions) => Effect.Effect; + readonly open: (options: OpenOptions) => Effect.Effect; readonly discover: ( options: Parameters[0], ) => Effect.Effect; diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 2813d3466b..7389f20a2f 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -79,7 +79,10 @@ const databaseReady = Effect.fn("StackLocalDatabase.ready")(function* (stack: St return Option.none<{ readonly stack: Stack; readonly database: DatabaseInstance }>(); const observation = yield* database.status.pipe( Effect.map(Option.some), - Effect.catchTag("StackError", () => Effect.succeed(Option.none())), + Effect.catchIf( + (cause) => cause.reason === "owner-unavailable", + () => Effect.succeed(Option.none()), + ), ); if ( Option.isNone(observation) || @@ -157,7 +160,7 @@ export const stackProjectDatabaseVersion: Effect.Effect< if (database === undefined) return undefined; const status = yield* database.status.pipe(Effect.option, Effect.map(Option.getOrUndefined)); return status?.config.service === "database" ? status.config.config.version : undefined; -}); +}).pipe(Effect.scoped); export class StackNativeEngineError extends Data.TaggedError("StackNativeEngineError")<{ readonly message: string; @@ -197,7 +200,7 @@ const stackLocalDatabaseUrl: Effect.Effect< const password = Redacted.value(status.config.config.databasePassword); const host = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host; return `postgresql://postgres:${encodeURIComponent(password)}@${host}:${endpoint.port}/postgres`; -}); +}).pipe(Effect.scoped); export const stackLocalDatabaseConn: Effect.Effect< PgConnInput, diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index a11bd98e56..a33137d272 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -62,6 +62,7 @@ const acquireNamespace = Effect.fn("StackShadow.acquireNamespace")(function* (op stateRoot: path.join(settings.supabaseHome, "stacks"), cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), runtime, + lifetime: "session", }); return { stack, runtime }; }); @@ -212,7 +213,10 @@ const initialize = Effect.fn("StackShadow.initialize")(function* ( } satisfies StackShadowAcquiredHandle; }); -/** Acquires a fresh shadow for callers whose enclosing scope owns its lifetime. */ +/** + * Acquires a fresh shadow for callers whose enclosing scope owns its lifetime. The shadow is a + * session stack, so its owner destroys it even when this process exits abruptly. + */ export const stackAcquireShadowDatabase = Effect.fn("StackShadow.acquire")(function* ( input: ShadowSetupInput, opts: ShadowOptions = {}, @@ -229,10 +233,7 @@ export const stackAcquireShadowDatabase = Effect.fn("StackShadow.acquire")(funct : Effect.void, ), Effect.catch((cause) => - output.raw( - `Failed to destroy shadow stack ${stack.id}: ${cause.message}. Run supabase stack destroy --stack-id ${stack.id} to remove it.\n`, - "stderr", - ), + output.raw(`Failed to destroy shadow stack ${stack.id}: ${cause.message}.\n`, "stderr"), ), ), ); diff --git a/apps/cli/src/command-internal/stack-storage.ts b/apps/cli/src/command-internal/stack-storage.ts index 0e5fd41017..45b8fc9d84 100644 --- a/apps/cli/src/command-internal/stack-storage.ts +++ b/apps/cli/src/command-internal/stack-storage.ts @@ -214,7 +214,7 @@ export const stackStorageEndpoint: Effect.Effect< suggestion: "Run supabase start to create it.", }); return yield* stackStorageEndpointFor(opened.value); -}); +}).pipe(Effect.scoped); /** * Maps a stack-gateway activation failure into `StackStorageCapabilityError` guidance, only for diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index d46bbea1e3..28d0420730 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -155,6 +155,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { runtime, instances: [], composition: { members: [{ id, activation: "eager" as const }], dependencies: [] }, + lifetime: "detached" as const, ports: [], }, host: undefined, diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 6158f75b0e..cec5f895d1 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -61,13 +61,14 @@ import { StackCatalogSetup, type StackCatalogSetupInput, } from "../../../command-internal/stack-catalog-setup.ts"; -import type { - ServiceCreation, - ServiceInstance, - ServiceInstances, - StackCredentials, - Stack, - Observation, +import { + StackError, + type ServiceCreation, + type ServiceInstance, + type ServiceInstances, + type StackCredentials, + type Stack, + type Observation, } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; @@ -544,7 +545,7 @@ const serviceCreation = ( const stackService = ( id: string, creation: Extract, - observation: Effect.Effect, + observation: Effect.Effect, overrides: { readonly start?: Effect.Effect; readonly ready?: Effect.Effect; @@ -637,6 +638,8 @@ function mockResetStackApi(opts: { readonly apiEndpoint?: { readonly url: string; readonly port: number }; /** Models the `db start` overlay, whose composition holds only the database. */ readonly postgresOnly?: boolean; + /** Fails every database status read, as an unreachable or mismatched owner does. */ + readonly statusFailure?: StackError; }) { let resetCalls = 0; let stopCalls = 0; @@ -704,13 +707,16 @@ function mockResetStackApi(opts: { }), ), ); - const dbStatus = Effect.sync(() => - makeStackObservation(DB_ID, database, { - lifecycle: databaseRunning ? "running" : "stopped", - health: databaseRunning ? "healthy" : undefined, - endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54329 }], - }), - ); + const dbStatus = + opts.statusFailure === undefined + ? Effect.sync(() => + makeStackObservation(DB_ID, database, { + lifecycle: databaseRunning ? "running" : "stopped", + health: databaseRunning ? "healthy" : undefined, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54329 }], + }), + ) + : Effect.fail(opts.statusFailure); const db = stackService(DB_ID, database, dbStatus, { resetData: Effect.suspend(() => databaseRunning @@ -807,6 +813,7 @@ function mockResetStackApi(opts: { id: member.id, creation: { service: "mail" as const, config: {} }, })), + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }, @@ -883,6 +890,7 @@ function setup( stackStorageError?: string; stackApiEndpoint?: { readonly url: string; readonly port: number }; stackPostgresOnly?: boolean; + stackStatusFailure?: StackError; httpClient?: Layer.Layer; }, ) { @@ -925,6 +933,7 @@ function setup( storageError: opts.stackStorageError, apiEndpoint: opts.stackApiEndpoint, postgresOnly: opts.stackPostgresOnly, + statusFailure: opts.stackStatusFailure, }); const catalog = opts.stackBackend === true @@ -1359,6 +1368,49 @@ describe("db reset", () => { }); }); + it.live("reports a stack whose owner is absent as not running", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackStatusFailure: new StackError({ + operation: "status", + message: "Stack owner is not running", + reason: "owner-unavailable", + }), + }); + return Effect.gen(function* () { + const error = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "ResetLocalDbNotRunningError", + message: "The local stack is not running.", + }); + expect(stackApi.resetCalls).toBe(0); + }); + }); + + it.live("surfaces a release mismatch instead of reporting the stack as not running", () => { + const message = + "Stack test is served by release 1.0.0+old, but this client is release 1.0.0+new; stop or destroy the stack (both work across releases) and retry"; + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackStatusFailure: new StackError({ + operation: "status", + message, + reason: "release-mismatch", + }), + }); + return Effect.gen(function* () { + const error = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ _tag: "StackError", message }); + expect(stackApi.resetCalls).toBe(0); + }); + }); + const BUCKET_TOML = ['project_id = "test"', "[storage.buckets.dogfood]", "public = true"].join( "\n", ); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 11c9cbde51..189ff86b68 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -179,6 +179,7 @@ function generateStackApi(workdir: string) { runtime: "native", instances: [], composition, + lifetime: "detached" as const, ports: [], }, host: undefined, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index f9f6b352ab..10fda76a0e 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -181,6 +181,7 @@ function syncStackApi(workdir: string, port: number) { runtime: "native", instances: [], composition, + lifetime: "detached" as const, ports: [], }, host: undefined, diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 7fb1f1f4c9..514fba89f0 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -87,6 +87,7 @@ export const declarativeSeamLayer = Layer.effect( if (backend.kind === "stack") { return yield* stackEnsurePostgresOnlyStarted().pipe( Effect.asVoid, + Effect.scoped, Effect.provideContext(context), Effect.provideService(StackApi, stackApi), Effect.provideService(ExperimentalFlag, experimentalFlag), diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 04000a56b1..5bfdb14e1a 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1725,6 +1725,7 @@ describe("db start stack backend", () => { identity: { projectRoot: root, branchContext: "main", stackName: "default" }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }, diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index 1a19d2f108..603bb49d7c 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -31,6 +31,7 @@ const stack = ( identity, runtime, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index db87ef1bc6..942f7c1fcb 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -85,9 +85,9 @@ When configured, initial Storage bucket seeding creates buckets and uploads thei files using the service-role JWT. Storage is started and made ready before those requests. A resumed stack is not re-seeded. Projects without configured buckets make no bucket-seeding requests. -A new stack is registered and then its owner is launched; if the owner fails to launch (for example, -Docker is unavailable) or the launch is interrupted, the registration is removed, and the CLI reports -the single launch failure with no separate stop diagnostic. Any other failure or interruption during the first startup stops and +A new stack is registered by its owner once that owner starts; if the owner fails to start (for +example, Docker is unavailable) or the launch is interrupted, it removes that registration, and the +CLI reports the single launch failure with no separate stop diagnostic. Any other failure or interruption during the first startup stops and unconfigures that initial composition, then destroys only the service instances created by this invocation. When startup began without a running owner, failure cleanup stops any owner launched during startup and waits for its exit. A target with a running owner keeps it. Existing instances and diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 6a31527d8d..2c1b7bd86b 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -184,6 +184,7 @@ const runStatus = (input: { creation: service === "database" ? database : rest, })), composition: { members: input.members ?? [], dependencies: [] }, + lifetime: "detached" as const, ports: [], }; const api = Layer.succeed(StackApi, { @@ -201,6 +202,7 @@ const runStatus = (input: { identity: definition.identity, pid: 123, port: 4567, + release: "test", }, }, ]), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index b2fd2ef858..4f0cab3132 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -120,6 +120,7 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: ? Effect.succeed({ id: definition.id, result: Result.succeed("unavailable" as const) }) : api.open({ ...locations, id: definition.id }).pipe( Effect.flatMap((stack) => stack.stop), + Effect.scoped, Effect.as("stopped" as const), Effect.result, Effect.map((result) => ({ id: definition.id, result })), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 0e4f36a7b9..ea155b96bd 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -114,7 +114,13 @@ describe("stack stop", () => { Effect.succeed([ { ...saved, - host: { stackId: f.stack.id, identity: saved.definition.identity, pid: 1, port: 1 }, + host: { + stackId: f.stack.id, + identity: saved.definition.identity, + pid: 1, + port: 1, + release: "test", + }, }, ]), open: () => diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index 68a5e770c1..cf37d70309 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -298,6 +298,7 @@ const fixture = ( identity, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }, diff --git a/apps/cli/tests/helpers/stack-cleanup.ts b/apps/cli/tests/helpers/stack-cleanup.ts index f472487a13..1a33dff392 100644 --- a/apps/cli/tests/helpers/stack-cleanup.ts +++ b/apps/cli/tests/helpers/stack-cleanup.ts @@ -14,7 +14,7 @@ export const destroyTestStacks = ( ({ definition }) => api .open({ id: definition.id, stateRoot, cacheRoot }) - .pipe(Effect.flatMap(destroyTestStack), Effect.exit), + .pipe(Effect.flatMap(destroyTestStack), Effect.scoped, Effect.exit), { concurrency: "unbounded" }, ); const failures = exits.filter(Exit.isFailure); diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index e9088cf51c..35b6f6bad3 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -274,6 +274,7 @@ export function buildStorageStackApi( ...(storageEnabled ? [{ id: storage.id, creation: storageCreation }] : []), ], composition: { members, dependencies: [] }, + lifetime: "detached" as const, ports: [], }; const findStackCalls: Array<{ readonly projectRoot: string }> = []; diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 30aad108ef..049082e6ae 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -429,13 +429,13 @@ flowchart TB Keep Effect RPC as the transport initially. Composition startup calls the executors directly, not RPC back into its own host. Replacing RPC with handwritten messages would still require framing, validation, errors and stream transport; that replacement is not part of this simplification. -| Module | Responsibility | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `StackHost` | Process lifetime: signals, control-port claim, startup container sweep, shutdown state machine, `/identity` and `/rpc`; serves the owner's handlers plus shutdown and command RPCs | -| `Owner` | Builds the instance and composition RPC handlers, maps domain failures to `StackError` once, persists definitions and adapts recipes, listeners and the Supabase composition | -| `Orchestrator` | The single in-memory registry of instance entries and the composition: admission, start plans, activity, idle sleep and exit watchers | -| `Network` | Public listeners, the shared API proxy and port claims | -| `host/*` | Service-specific endpoint routes, rendered connection values and stack credential rules | +| Module | Responsibility | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `StackHost` | Process lifetime: lease, signals, session lifeline, sweeps, shutdown state machine; serves `/identity`, `POST /shutdown` and `/rpc` (owner handlers plus command RPCs) | +| `Owner` | Builds the instance and composition RPC handlers, maps domain failures to `StackError` once, persists definitions and adapts recipes, listeners and the Supabase composition | +| `Orchestrator` | The single in-memory registry of instance entries and the composition: admission, start plans, activity, idle sleep and exit watchers | +| `Network` | Public listeners, the shared API proxy and port claims | +| `host/*` | Service-specific endpoint routes, rendered connection values and stack credential rules | Definition changes (service creation and destruction, composition configuration and Supabase composition) run one at a time in the owner's scope, and a caller that disconnects stops waiting. Creation saves the instance before registering it and removes the saved instance again if registration fails. Until registration completes, a concurrent `get` or `list` can already return the new id, while `start` or `status` for it fails with an unknown-instance error. @@ -470,11 +470,19 @@ stateDiagram-v2 Exited --> [*] ``` -During Starting, acquire the exclusive stack lease, load the instance definitions and saved resources needed for normal restart, construct components and open the control endpoint. The lease is held by an operating-system locking primitive whose ownership ends with the process; an on-disk PID/endpoint file is only discovery metadata and never proves an active owner. A competing launcher connects to the winning owner. For a container stack, remove containers labeled with this stack and its canonical data root before accepting work. This is cleanup of proven-owned workloads, not adoption or replay of interrupted operations. Other runtime or port conflicts remain errors. +During Starting, acquire the exclusive stack lease, load the instance definitions and saved resources needed for normal restart, construct components and open the control endpoint. For a container stack, remove containers labeled with this stack and its canonical data root before accepting work. This is cleanup of proven-owned workloads, not adoption or replay of interrupted operations. Other runtime or port conflicts remain errors. + +**Lease and discovery.** The lease is a SQLite write transaction on `//owner.lock`, held for the owner's whole lifetime; the operating system releases it when the process exits for any reason. POSIX record locks belong to the process, so only the lease code opens that file. A waiter may open the file just before a releasing holder unlinks it; after locking, it checks that the path still names the file it locked and otherwise reopens. A stack is live exactly while its lease is held. After taking the lease the owner binds a loopback control listener on an OS-assigned port and, once it serves, atomically publishes `owner.json`, readable only by its user, with its port, PID, release, lifetime, start time and a random per-owner secret; it retracts the record on exit. A client first try-locks the lease: a free lease means no owner, so `discover` reports dead stacks without any network probe. Only while the lease is held does a client read `owner.json`, then validate the stack identity, PID and port through `GET /identity`. Every control request carries the secret as `Authorization: Bearer `, a header HTTP tracing redacts, and the owner rejects requests without it after a constant-time comparison. A stale record, a foreign listener on a previous control port, or another owner that later binds that port therefore never receives this stack's requests; a client treats a rejection as a stale record and resolves the owner again. Concurrent spawners may start several owners; each loser fails to take the lease, reports that on its readiness descriptor and exits, and its spawner attaches to the winner through `owner.json`. + +**Release handshake.** `/identity` reports the owner's release: the package version plus a build identifier. The identifier is a digest of this package's module sources and its Effect version: the CLI build scripts embed it in every compiled binary and a source checkout computes it, so a binary and a source run of the same sources interoperate, and any change to the owner or its protocol is a new release. Operations fail with an error asking the user to stop or destroy the stack when the releases differ. `stop` and `destroy` use `POST /shutdown` with the bearer secret and a `{ "destroy": boolean }` body, which do not depend on the RPC schema and so reach owners of any release. + +**Session lifetime.** A session stack is registered by its owner under the lease, so it never exists without a live owner except after that owner dies. Its spawner keeps the owner's stdin pipe open for the life of the creating handle. End of input means the creator is gone, whether it closed the handle or its process died: the owner destroys the stack and exits. Only the creating handle starts a session stack's owner; other handles attach. + +**Orphan sweep.** Stack-labelled containers and session stacks exist only while their lease is held. After readiness, each owner visits every other stack in its state root in the background, with a bounded time per stack. It skips stacks whose lease is held. For a free lease it takes that lease for the duration of the visit, publishing a sweeper record in `owner.json` so clients wait for the visit instead of mistaking it for a starting owner, removes containers labelled with the stack and its data root, and destroys the stack through the owner's own destroy path when its lifetime is `session`. Filtering on the data-root label keeps other state roots untouched. Creating a stack whose identity belongs to a dead session stack reclaims that stack the same way first. During Serving, keep the owner alive independently of callers. Sleeping instances still need its public listeners. This is process lifetime management, not automatic service restart or continuous reconciliation. -Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and commands, then synchronously removes containers labeled with the stack and canonical data root before the host exits. Forced termination may require manual cleanup. +Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and commands, then synchronously removes containers labeled with the stack and canonical data root before the host exits. After forced termination, the orphan sweep removes leftover containers. During Draining: @@ -485,13 +493,13 @@ During Draining: 5. For destruction, remove proven-owned data and metadata after shutdown. 6. Send the outcome, close the control endpoint and release ownership. -**Successful whole-stack shutdown.** `stack.stop()` reports success only after admitted work settles, every owned live service and command workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close, the shutdown RPC is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the live host retains ownership for inspection and retry; the stack is not reported stopped. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and saved port assignments. `destroy` follows the same live-workload cleanup, then removes only proven-owned persistent data. +**Successful whole-stack shutdown.** `stack.stop()` reports success only after admitted work settles, every owned live service and command workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close, the shutdown request is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the live host retains ownership for inspection and retry; the stack is not reported stopped. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and saved port assignments. `destroy` follows the same live-workload cleanup, then removes only proven-owned persistent data. -The client captures the live owner PID from the validated identity endpoint or readiness handshake, completes and closes the shutdown RPC, then performs bounded process-existence checks. An absent PID confirms exit; a permission-denied probe remains inconclusive until the deadline. Failure to confirm exit is a `shutdown-exit` error carrying the PID in its message, even when workload cleanup has already succeeded. This does not require persisted PID records or forceful termination. Caller cancellation ends its wait without cancelling admitted owner cleanup. +The client captures the live owner PID from the validated identity endpoint or readiness handshake, completes the shutdown request, then performs bounded process-existence checks. An absent PID confirms exit; a permission-denied probe remains inconclusive until the deadline. Failure to confirm exit is a `shutdown-exit` error carrying the PID in its message, even when workload cleanup has already succeeded. This does not require persisted PID records or forceful termination. Caller cancellation ends its wait without cancelling admitted owner cleanup. -Callers must not start or restart the same stack concurrently with whole-stack shutdown. In particular, replacing an owner between identity lookup and the shutdown request is outside this guarantee. Parallel stacks with separate identities remain independent. Client disposal and Effect scope closure do not implicitly stop a detached stack; disposable fixtures register explicit destruction. +Callers must not start or restart the same stack concurrently with whole-stack shutdown. In particular, replacing an owner between identity lookup and the shutdown request is outside this guarantee. Parallel stacks with separate identities remain independent. Client disposal and Effect scope closure do not implicitly stop a detached stack; disposable fixtures register explicit destruction or use a session stack, which closing its creating handle destroys. -Returning to Serving after cleanup failure does not undo completed cleanup. Unexpected host death does not resume interrupted operations or restore live service state. The next host startup sweeps containers owned by that stack and data root without removing volumes or saved definitions. A forced host termination does not guarantee immediate container cleanup. A lost control response is reported as uncertain; do not blindly retry a mutation. +Returning to Serving after cleanup failure does not undo completed cleanup. Unexpected host death does not resume interrupted operations or restore live service state. Native launchers stop their process groups when the dead host's pipe closes. The next host startup for that stack sweeps its containers without removing volumes or saved definitions, and any host start in the same state root sweeps them in the background, also destroying the stack if it is a session stack. A forced host termination does not guarantee immediate container cleanup. A lost control response is reported as uncertain; do not blindly retry a mutation. ### Request lifetime is separate from execution lifetime @@ -621,6 +629,9 @@ The state root is the stack registry root. Each stack keeps one state document a ```text //state.json //data//... +//owner.lock lease; opened only by SQLite +//owner.json endpoint of the lease holder +//owner.log owner stdout and stderr, truncated at each owner start ``` Registry updates use an OS-backed lock through a private `node:sqlite` connection to @@ -632,7 +643,7 @@ SQLite. No tables, state records, or WAL are created there. Saved stack data rem the lock does not make multi-file operations transactional or recover interrupted operations. This uses the built-in SQLite API available in the pinned Bun runtime and modern Node.js. -The artifact cache is independent and shared across stacks. Normal stop preserves the stack directory and service data. Destroy removes the state document and proven-owned, empty parents; caller-owned paths such as Storage uploads remain untouched. +The artifact cache is independent and shared across stacks. Normal stop preserves the stack directory and service data. Destroy removes the state document, owner files and proven-owned, empty parents; the lease file goes last, while its lock is still held; caller-owned paths such as Storage uploads remain untouched. ### Snapshots belong to the database instance diff --git a/packages/stack/README.md b/packages/stack/README.md index 63bd492cf2..07aad604d9 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -67,11 +67,15 @@ On Linux, native Functions project files must be outside `/tmp`: Edge Runtime us `open({ id, stateRoot, cacheRoot })` reconnects to a saved stack. The package stores the stack document at `//state.json` and service data at `//data/`. `discover({ stateRoot })` lists saved definitions and port assignments separately from live-owner availability. It skips each entry that cannot be read or decoded and reports it to `onInvalidState(id, error)`; only a failure to read `stateRoot` itself fails discovery. Port allocation skips the same entries. Offline definitions are not live lifecycle observations. -Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. +Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. The owner writes its output to `//owner.log`, which each owner start truncates; owner start and connection failures name that file. A client drives only an owner of its own release; other operations fail and ask you to stop or destroy the stack, which work across releases. -`create({ ..., startOwner: true })` registers the stack and then starts its owner immediately; if the owner fails to launch or the launch is interrupted, `create` removes the registration it just saved, unless an owner already holds the stack, and fails with the launch error. +### Lifetimes -`destroy` normally returns `{ runtimeCleanup: "complete" }`. When no owner is running and the stack's container engine reports that its daemon cannot be reached, `destroy` removes the local registration and host data anyway and returns `{ runtimeCleanup: "skipped", engine, cleanupCommands }`; its containers and any database data in engine volumes remain, and `cleanupCommands` are the shell commands that remove them once the engine is running. If some host data cannot be deleted by the current user, `destroy` fails before removing anything so it can be retried with the engine running. +`create` defaults to `lifetime: "detached"`: the stack and its owner outlive the creating client. `create({ ..., lifetime: "session" })` starts the owner immediately and ties the stack to the creating client. Closing that client, or its process exiting for any reason, destroys the stack: instances stop, data and registrations are removed, and port claims are released. Other clients may attach to a running session stack but cannot start its owner. A session stack whose owner has stopped is disposable: the next owner start in the state root removes it, and creating a stack with the same identity replaces it. + +`create({ ..., startOwner: true })` starts a detached stack's owner immediately and lets it register the stack under its lease, as a session stack always does. If the owner fails to start or the launch is interrupted before it reports ready, the owner removes that registration and `create` fails with the launch error, so a failed launch leaves no stack behind. + +`destroy` normally returns `{ runtimeCleanup: "complete" }`. When no owner is running and a new owner cannot start because the stack's container engine reports that its daemon cannot be reached, `destroy` takes the stack's lease, so no owner can start meanwhile, and removes the local registration, port claims and host data anyway and returns `{ runtimeCleanup: "skipped", engine, cleanupCommands }`; its containers and any database data in engine volumes remain, and `cleanupCommands` are the shell commands that remove them once the engine is running. If some host data cannot be deleted by the current user, `destroy` fails before removing anything so it can be retried with the engine running. The stack owns database, Functions bootstrap, and command-job directories below its data directory. Storage uploads remain at the caller-supplied Storage `filePath` and are preserved when the stack is destroyed; the caller owns that directory. Host metadata remains under `stateRoot`; native database data uses host files. Docker database data normally uses a managed volume, while existing host data is retained through the host-backed fallback. A host marker records the selected Docker storage and detects a missing or mismatched volume; deleting that volume loses the associated database data. Native snapshot entries live below `cacheRoot`. Docker snapshots share the managed data volume in a separate namespace derived from `cacheRoot`, so they survive source destruction and can use filesystem cloning. A Docker cache hit requires the same daemon, `stateRoot`, and `cacheRoot`. There is no portable tar snapshot API. @@ -141,13 +145,13 @@ Bindings supply ordinary configuration values; a URL alone never creates a depen - Instance methods affect that instance, subject to dependency checks. - Composition methods affect selected members; startup also includes declared prerequisites. -- `stack.stop()` stops every owned instance and attached command and returns after confirming owner exit. It requires a live owner; an unavailable owner cannot confirm cleanup. +- `stack.stop()` stops every owned instance and attached command and returns after confirming owner exit. Without a live owner nothing runs, so it returns without starting one; an instance's `stop()` behaves the same way. - `stack.destroy()` additionally removes owned data and registrations, and also waits for owner exit. -- `stack.close()` disposes the client and invalidates its active observation iterators. Stopping the last instance leaves the owner available. +- `stack.close()` disposes the client and invalidates its active observation iterators. Closing the creating client of a session stack destroys the stack. Stopping the last instance leaves the owner available. Exit confirmation is bounded. If cleanup is acknowledged but owner exit cannot be confirmed, the operation fails with `operation: "shutdown-exit"` and the owner PID in the message. A failed or cancelled call does not guarantee that teardown has completed. Do not start or restart the same stack concurrently with whole-stack shutdown; separate stacks remain independent. -Disposable test stacks need explicit teardown; closing a client does not own their lifetime: +A session stack is destroyed when its creating client closes. A detached test stack needs explicit teardown, because closing a client does not own its lifetime: ```ts try { @@ -165,7 +169,7 @@ Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observ ## Effect consumers -The Effect entrypoint exposes the same operations as Effects and Streams. Database secrets use `Redacted` in the Effect configuration: +The Effect entrypoint exposes the same operations as Effects and Streams. `create` and `open` return a handle that lasts until the enclosing `Scope` closes; closing the creating scope of a session stack destroys it. Database secrets use `Redacted` in the Effect configuration: ```ts import { NodeHttpClient, NodeServices } from "@effect/platform-node"; @@ -181,7 +185,10 @@ const program = Effect.gen(function* () { }); await Effect.runPromise( - program.pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + program.pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), ); ``` @@ -197,4 +204,4 @@ const test = Effect.acquireUseRelease( ); ``` -The owner supports normal stop/start persistence. Unexpected owner death does not trigger resource adoption, orphan removal, or interrupted-operation recovery. CLI integration is maintained separately from this package. +The owner supports normal stop/start persistence. When an owner dies unexpectedly, its native processes die with it. Its containers remain until the next owner start in the same `stateRoot` removes them; that start also destroys session stacks whose owner is gone. Unexpected owner death does not trigger resource adoption or interrupted-operation recovery. CLI integration is maintained separately from this package. diff --git a/packages/stack/package.json b/packages/stack/package.json index e889bf9666..c3269fa466 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -12,6 +12,7 @@ "./internal/identity": "./src/identity/Identity.ts", "./internal/functions/serve-main": "./src/functions/serve.main.ts", "./internal/postgres-artifact": "./src/internal/postgres-artifact.ts", + "./internal/release": "./src/internal/release.ts", "./internal/service-catalog": "./src/internal/service-catalog.ts" }, "scripts": { diff --git a/packages/stack/src/HostProcess.integration.test.ts b/packages/stack/src/HostProcess.integration.test.ts index b5bacdd389..f6a89455ba 100644 --- a/packages/stack/src/HostProcess.integration.test.ts +++ b/packages/stack/src/HostProcess.integration.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { Context, Data, + DateTime, Effect, FileSystem, Fiber, @@ -11,20 +12,70 @@ import { Path, Schema, Stream, + Tracer, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration verifies exact-port reopening. import * as Net from "node:net"; import { fileURLToPath } from "node:url"; -import { HostEndpoint, connectHost, controlPortHeld, launchHost } from "./HostProcess.ts"; +import { + HostEndpoint, + connectHost, + currentRelease, + launchHost, + shutdownHost, + type HostAccess, +} from "./HostProcess.ts"; +import { discover } from "./effect.ts"; +import { watchLeaseRelease } from "../tests/owner.ts"; import * as State from "./State.ts"; class ProcessTestError extends Data.TaggedError("ProcessTestError")<{ readonly message: string }> {} +const fixtureEntrypoint = fileURLToPath( + new URL("../tests/host-process-fixture.ts", import.meta.url), +); + +const savedStack = (root: string, stackName: string): State.SavedStack => ({ + id: "stack", + runtime: "native", + identity: { projectRoot: root, branchContext: "main", stackName }, + instances: [], + lifetime: "detached", + composition: { members: [], dependencies: [] }, + ports: [], +}); + +/** A loopback listener that accepts connections and never answers, counting each one. */ +const silentListener = Effect.acquireRelease( + Effect.callback< + { readonly server: Net.Server; readonly sockets: Set }, + ProcessTestError + >((resume) => { + const sockets = new Set(); + const server = Net.createServer((socket) => sockets.add(socket)); + server.once("error", (cause) => + resume(Effect.fail(new ProcessTestError({ message: cause.message }))), + ); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed({ server, sockets }))); + }), + ({ server, sockets }) => + Effect.callback((resume) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resume(Effect.void)); + }), +).pipe( + Effect.flatMap(({ server, sockets }) => { + const address = server.address(); + return typeof address === "object" && address !== null + ? Effect.succeed({ port: address.port, connections: () => sockets.size }) + : Effect.fail(new ProcessTestError({ message: "Silent listener has no port" })); + }), +); + const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( Effect.map((context) => Context.get(context, State.Service)), @@ -95,10 +146,14 @@ const waitForReady = (handle: ChildHandle) => Effect.flatMap((line) => Schema.decodeEffect( Schema.fromJsonString( - Schema.Struct({ type: Schema.Literal("ready"), endpoint: HostEndpoint }), + Schema.Struct({ + type: Schema.Literal("ready"), + endpoint: HostEndpoint, + secret: Schema.String, + }), ), )(line).pipe( - Effect.map((message) => message.endpoint), + Effect.map(({ endpoint, secret }): HostAccess => ({ endpoint, secret })), Effect.mapError((cause) => new ProcessTestError({ message: String(cause) })), ), ), @@ -122,27 +177,20 @@ const stopChild = (handle: ChildHandle) => }), ).pipe(Effect.asVoid); -const bestEffortShutdown = (client: HttpClient.HttpClient, endpoint: HostEndpoint) => - Effect.exit( - client - .execute( - HttpClientRequest.post(`http://127.0.0.1:${endpoint.port}/shutdown`).pipe( - HttpClientRequest.setHeader("connection", "close"), - ), - ) - .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)), - ).pipe(Effect.asVoid); +/** Stops an owner and waits for it to release its lease, so its files are no longer in use. */ +const bestEffortShutdown = (stateRoot: string) => (access: HostAccess) => + Effect.scoped( + Effect.gen(function* () { + const released = yield* watchLeaseRelease(stateRoot, access.endpoint.stackId); + yield* shutdownHost(access, false); + yield* released; + }), + ).pipe(Effect.exit, Effect.asVoid); -const bestEffortShutdownByState = ( - client: HttpClient.HttpClient, - state: State.Interface, - stackId: string, -) => - Effect.exit( - connectHost(state, stackId).pipe( - Effect.flatMap((endpoint) => bestEffortShutdown(client, endpoint)), - ), - ).pipe(Effect.asVoid); +const bestEffortShutdownByState = (stateRoot: string, state: State.Interface, stackId: string) => + Effect.exit(connectHost(state, stackId).pipe(Effect.flatMap(bestEffortShutdown(stateRoot)))).pipe( + Effect.asVoid, + ); const bindExact = (port: number) => Effect.callback((resume) => { @@ -189,7 +237,7 @@ const waitForMarker = (marker: string) => }), ); -it.live("launches one detached owner and attaches competing launchers", () => +it.live("starts exactly one owner for concurrent launchers and attaches the others", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -200,6 +248,7 @@ it.live("launches one detached owner and attaches competing launchers", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "local" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -212,73 +261,128 @@ it.live("launches one detached owner and attaches competing launchers", () => stackId: "stack", entrypoint, }; - const client = yield* HttpClient.HttpClient; yield* Effect.gen(function* () { - const [first, second] = yield* Effect.all( - [launchHost(state, options), launchHost(state, options)], - { concurrency: 2 }, + const launched = yield* Effect.all( + [launchHost(state, options), launchHost(state, options), launchHost(state, options)], + { concurrency: "unbounded" }, ); - expect(second.port).toBe(first.port); - expect(second.pid).toBe(first.pid); - }).pipe(Effect.ensuring(bestEffortShutdownByState(client, state, "stack"))); + expect(new Set(launched.map(({ endpoint }) => endpoint.pid)).size).toBe(1); + expect(new Set(launched.map(({ endpoint }) => endpoint.port)).size).toBe(1); + }).pipe(Effect.ensuring(bestEffortShutdownByState(root, state, "stack"))); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); -it.live("lets an external launcher attach, then reopens the owner port after shutdown", () => +it.live("relaunches after shutdown while a foreign listener holds the previous control port", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-launcher-" }); const state = yield* makeTestState(root); - yield* state.save({ - id: "stack", - runtime: "native", - identity: { projectRoot: root, branchContext: "main", stackName: "local" }, - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], - }); - const entrypoint = fileURLToPath( - new URL("../tests/host-process-fixture.ts", import.meta.url), - ); - const args = [entrypoint, root, root, "stack"]; + yield* state.save(savedStack(root, "local")); + const args = [fixtureEntrypoint, root, root, "stack"]; const owner = yield* Effect.acquireRelease( spawnChild(args, ["ignore", "ignore", "ignore", "pipe"]), stopChild, ); - const endpoint = yield* waitForReady(owner); - const observedEndpoint = yield* connectHost(state, "stack"); + const access = yield* waitForReady(owner); + const { endpoint } = access; + const observed = yield* connectHost(state, "stack"); const client = yield* HttpClient.HttpClient; - expect(observedEndpoint.pid).toBe(endpoint.pid); + expect(observed.endpoint.pid).toBe(endpoint.pid); const ownerExitFiber = yield* waitForClose(owner).pipe(Effect.forkScoped); yield* Effect.gen(function* () { - const response = yield* client.execute( + const unauthenticated = yield* client.execute( HttpClientRequest.post(`http://127.0.0.1:${endpoint.port}/shutdown`).pipe( - HttpClientRequest.setHeader("connection", "close"), + HttpClientRequest.bodyJsonUnsafe({ destroy: true }), ), ); - yield* HttpClientResponse.filterStatusOk(response); + expect(unauthenticated.status, "shutdown requires the owner secret").toBe(401); + expect(Option.isNone(yield* shutdownHost(access, false))).toBe(true); const ownerExit = yield* Fiber.join(ownerExitFiber); owner.closed = true; owner.closeCode = ownerExit.code; owner.closeSignal = ownerExit.signal; expect(ownerExit.signal).toBeNull(); - }).pipe(Effect.ensuring(bestEffortShutdown(client, endpoint))); - yield* Effect.scoped( - Effect.gen(function* () { - const reopened = yield* Effect.acquireRelease(bindExact(endpoint.port), closeServer); - expect(reopened.listening).toBe(true); - }), - ); + }).pipe(Effect.ensuring(bestEffortShutdown(root)(access))); + yield* Effect.acquireRelease(bindExact(endpoint.port), closeServer); const relaunched = yield* Effect.acquireRelease( - launchHost(state, { stateRoot: root, cacheRoot: root, stackId: "stack", entrypoint }).pipe( - Effect.provide(FetchHttpClient.layer), - ), - (next) => bestEffortShutdown(client, next), + launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fixtureEntrypoint, + }).pipe(Effect.provide(FetchHttpClient.layer)), + bestEffortShutdown(root), ); - expect(relaunched.port).toBe(endpoint.port); - expect(relaunched.pid).not.toBe(endpoint.pid); + expect(relaunched.endpoint.pid).not.toBe(endpoint.pid); + expect(relaunched.endpoint.port).not.toBe(endpoint.port); + expect(yield* connectHost(state, "stack")).toEqual(relaunched); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("ignores a stale endpoint record once no process holds the lease", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-stale-" }); + const state = yield* makeTestState(root); + yield* state.save(savedStack(root, "local")); + const unresponsive = yield* silentListener; + yield* state.publishHolder("stack", { + role: "owner", + secret: "stale", + port: unresponsive.port, + pid: process.pid, + release: yield* currentRelease, + lifetime: "detached", + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + const [live] = yield* discover({ stateRoot: root }); + expect(live?.host).toBeUndefined(); + const launched = yield* Effect.acquireRelease( + launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fixtureEntrypoint, + }), + bestEffortShutdown(root), + ); + expect(launched.endpoint.port).not.toBe(unresponsive.port); + const published = yield* state.readHolder("stack"); + expect(published?.role === "owner" ? published.port : undefined).toBe(launched.endpoint.port); + expect(unresponsive.connections()).toBe(0); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("discovers dead stacks from their free leases without contacting recorded endpoints", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-discover-" }); + const state = yield* makeTestState(root); + const unresponsive = yield* silentListener; + const ids = ["dead-a", "dead-b", "dead-c"]; + for (const id of ids) { + yield* state.save({ ...savedStack(root, id), id }); + yield* Effect.scoped(state.lease(id)); + yield* state.publishHolder(id, { + role: "owner", + secret: "stale", + port: unresponsive.port, + pid: process.pid, + release: yield* currentRelease, + lifetime: "detached", + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + } + const entries = yield* discover({ stateRoot: root }); + expect(entries.map(({ definition }) => definition.id).toSorted()).toEqual(ids); + expect(entries.every(({ host }) => host === undefined)).toBe(true); + expect(unresponsive.connections()).toBe(0); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -298,13 +402,13 @@ it.live( runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "local" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); const entrypoint = fileURLToPath( new URL("../tests/host-process-fixture.ts", import.meta.url), ); - const client = yield* HttpClient.HttpClient; yield* Effect.gen(function* () { const args = [entrypoint, root, root, "stack", "launcher", entrypoint]; const launcher = yield* Effect.acquireRelease( @@ -315,8 +419,8 @@ it.live( const launcherExit = yield* waitForClose(launcher); expect(launcherExit.code).toBe(0); const connected = yield* connectHost(state, "stack"); - expect(connected).toEqual(endpoint); - }).pipe(Effect.ensuring(bestEffortShutdownByState(client, state, "stack"))); + expect(connected.endpoint).toEqual(endpoint); + }).pipe(Effect.ensuring(bestEffortShutdownByState(root, state, "stack"))); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -333,6 +437,7 @@ it.live("terminates a detached child when readiness is interrupted", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "slow-handshake" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -358,27 +463,57 @@ it.live("terminates a detached child when readiness is interrupted", () => ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); -it.live("reports a stack's control port as held while a listener binds it", () => - Effect.gen(function* () { - const listener = yield* Effect.acquireRelease(bindExact(0), closeServer); - const address = listener.address(); - if (address === null || typeof address === "string") - return yield* new ProcessTestError({ message: "listener has no TCP address" }); - const saved: State.SavedStack = { - id: "stack", - runtime: "docker", - identity: { projectRoot: "/project", branchContext: "main", stackName: "local" }, - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], - }; +it.live("keeps the owner log tail in a start failure after the owner removed its log", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-failed-start-" }); + const state = yield* makeTestState(root); + const failure = yield* launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fileURLToPath(new URL("../tests/failing-owner-fixture.ts", import.meta.url)), + register: savedStack(root, "failing"), + }).pipe(Effect.flip); + expect(failure.message).toContain("owner startup failed"); + expect(failure.message).toContain("failing-owner-diagnostic"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); - expect(yield* controlPortHeld(saved)).toBe(false); - expect( - yield* controlPortHeld({ - ...saved, - ports: [{ key: "control", host: "127.0.0.1", port: address.port }], - }), - ).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +it.live("keeps the owner secret out of recorded HTTP span attributes", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-tracing-" }); + const state = yield* makeTestState(root); + yield* state.save(savedStack(root, "local")); + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const access = yield* launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fixtureEntrypoint, + }).pipe( + Effect.andThen(connectHost(state, "stack")), + Effect.tap(bestEffortShutdown(root)), + Effect.withTracer(tracer), + ); + const attributes = spans.flatMap((span) => Array.from(span.attributes)); + const authorization = attributes.filter( + ([key]) => key === "http.request.header.authorization", + ); + expect(authorization.length, "identity and shutdown requests were traced").toBeGreaterThan(1); + expect(authorization.every(([, value]) => value === "")).toBe(true); + expect(attributes.filter(([, value]) => String(value).includes(access.secret))).toEqual([]); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 2a7ed88376..3ff1146085 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -1,16 +1,80 @@ -import { NodeHttpServer } from "@effect/platform-node"; -import { Data, Effect, Duration, Exit, Option, Schedule, Schema, Scope, Stream } from "effect"; -import * as HttpServer from "effect/unstable/http/HttpServer"; +import { NodeStream } from "@effect/platform-node"; +import { + Crypto, + Data, + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schedule, + Schema, + Scope, + Stream, +} from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Effect ChildProcess cannot hand the owner a log file descriptor or release its lifeline pipe from the event loop. +import { spawn, type ChildProcess } from "node:child_process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the spawner shares its owner log descriptor with the owner and reads the log tail through it. +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; import { fileURLToPath } from "node:url"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.make requires a native server factory. -import * as Http from "node:http"; +import packageJson from "../package.json" with { type: "json" }; import { HOST_PROCESS_DISPATCH_SENTINEL, isBunVirtualPath } from "./internal/dispatch-markers.ts"; -import { makePorts, PortError } from "./Ports.ts"; -import type * as State from "./State.ts"; +import { failureMessage } from "./internal/failure-message.ts"; +import { stackSourceDigest } from "./internal/release.ts"; +import { StackRpc } from "./Rpc.ts"; +import { SavedStack, type Interface as StateInterface, type StateError } from "./State.ts"; + +declare const SUPABASE_STACK_BUILD_ID: string | undefined; + +/** + * A compiled CLI embeds the digest of the stack sources it was built from; a source checkout + * computes it, so both agree for the same sources and any change to them is a new release. + */ +const computeRelease = Effect.gen(function* () { + if (typeof SUPABASE_STACK_BUILD_ID === "string") + return `${packageJson.version}+${SUPABASE_STACK_BUILD_ID}`; + if (isBunVirtualPath(fileURLToPath(import.meta.url))) { + const fs = yield* FileSystem.FileSystem; + const binary = yield* fs.stat(process.execPath); + const modified = Option.match(binary.mtime, { onNone: () => 0, onSome: (at) => at.getTime() }); + return `${packageJson.version}+binary.${binary.size}.${modified}`; + } + return `${packageJson.version}+${yield* stackSourceDigest}`; +}).pipe(Effect.orDie); + +/** The release of this build, computed once per process; clients only drive owners of it. */ +export const currentRelease: Effect.Effect< + string, + never, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> = Effect.runSync(Effect.cached(computeRelease)); + +/** + * Control requests carry the owner secret as a bearer token, part of the release-stable contract; + * HTTP tracing redacts `authorization`, so the secret never reaches span attributes. + */ +export const ownerAuthorization = (secret: string) => `Bearer ${secret}`; + +/** Checks a request's `authorization` header against the owner secret in constant time. */ +export const authorizes = (header: string | undefined, secret: string) => { + const expected = ownerAuthorization(secret); + if (header === undefined || header.length !== expected.length) return false; + let difference = 0; + for (let index = 0; index < expected.length; index++) + difference |= expected.charCodeAt(index) ^ header.charCodeAt(index); + return difference === 0; +}; + +/** How long a sweeping owner may hold a dead stack's lease. */ +export const sweepTimeout = Duration.minutes(1); export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly operation: string; @@ -19,9 +83,12 @@ export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly reason?: HostFailureReason; }> {} type HostFailureReason = - | "missing-control-listener" + | "unregistered" + | "not-running" + | "sweeping" + | "owner-starting" | "connection-failure" - | "bind-conflict" + | "release-mismatch" | "runtime-unavailable" | "invalid-owner-pid" | "owner-exit-pending" @@ -31,87 +98,96 @@ const HostIdentity = Schema.Struct({ branchContext: Schema.String, stackName: Schema.String, }); -interface HostIdentity extends Schema.Schema.Type {} export const HostEndpoint = Schema.Struct({ stackId: Schema.String, identity: HostIdentity, pid: Schema.Int, port: Schema.Int, + release: Schema.String, }); export interface HostEndpoint extends Schema.Schema.Type {} + +/** A validated owner endpoint and the secret its control requests carry. */ +export interface HostAccess { + readonly endpoint: HostEndpoint; + readonly secret: string; +} + +/** The release-stable body of `POST /shutdown`. */ +export const ShutdownRequest = Schema.Struct({ destroy: Schema.Boolean }); +/** The release-stable body of a rejected `POST /shutdown`. */ +export const ShutdownFailure = Schema.Struct({ + message: Schema.String, + outcomes: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + id: Schema.String, + succeeded: Schema.Boolean, + error: Schema.optionalKey(Schema.String), + }), + ), + ), +}); +export interface ShutdownFailure extends Schema.Schema.Type {} + const error = (operation: string, cause: unknown, reason?: HostFailureReason) => new HostProcessError({ operation, - message: cause instanceof Error ? cause.message : String(cause), + message: failureMessage(cause), cause, ...(reason === undefined ? {} : { reason }), }); +/** Matches owner failures by reason. */ +export const hasReason = + (...reasons: ReadonlyArray) => + (failure: unknown) => + failure instanceof HostProcessError && + failure.reason !== undefined && + reasons.includes(failure.reason); -export const acquireHost = Effect.fn("HostProcess.acquireHost")(function* ( - state: State.Interface, - stackId: string, -): Effect.fn.Return< - { - readonly port: number; - readonly server: HttpServer.HttpServer["Service"]; - readonly closeConnections: Effect.Effect; - }, - HostProcessError | PortError | State.StateError, - Scope.Scope -> { - if ((yield* state.read(stackId)) === undefined) - return yield* error("acquire", "Stack is not registered"); - const ports = yield* makePorts(state); - let rawServer: Http.Server | undefined; - const acquired = yield* ports.acquire( - { stackId, key: "control", host: "127.0.0.1", port: "auto" }, - (host, port) => { - const server = Http.createServer(); - rawServer = server; - return NodeHttpServer.make(() => server, { host, port }).pipe( - Effect.mapError( - (cause) => - new PortError({ key: "control", message: "Cannot bind control listener", cause }), +const causeCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null) return undefined; + if ("code" in cause && typeof cause.code === "string") return cause.code; + if ("cause" in cause) return causeCode(cause.cause); + return undefined; +}; + +/** An RPC client whose requests carry the owner secret; a rejected secret is a status failure. */ +export const ownerClient = (access: HostAccess) => + RpcClient.make(StackRpc).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${access.endpoint.port}/rpc` }).pipe( + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide( + Layer.effect( + HttpClient.HttpClient, + HttpClient.HttpClient.pipe( + Effect.map((client) => + client.pipe( + HttpClient.mapRequest(HttpClientRequest.bearerToken(access.secret)), + HttpClient.filterStatusOk, + ), + ), + ), + ), ), - ); - }, + ), + ), ); - return { - port: acquired.port, - server: acquired.listener, - closeConnections: Effect.sync(() => { - rawServer?.closeAllConnections(); - rawServer?.closeIdleConnections(); - }), - }; -}); -/** - * Reports whether another process holds the stack's saved control port. An owner binds it under - * the state lock before it runs startup, so a caller holding that lock sees every owner that can - * still act on the stack. - */ -export const controlPortHeld = Effect.fn("HostProcess.controlPortHeld")(function* ( - stack: State.SavedStack, +/** Anything but a matching answer means the record names a process that no longer serves it. */ +const identityOf = Effect.fn("HostProcess.identityOf")(function* ( + stack: SavedStack, + owner: { readonly port: number; readonly pid: number; readonly secret: string }, ) { - const claim = stack.ports.find((entry) => entry.key === "control"); - if (claim === undefined) return false; - const probe = yield* Effect.scoped( - NodeHttpServer.make(() => Http.createServer(), { host: claim.host, port: claim.port }), - ).pipe(Effect.exit); - return Exit.isFailure(probe); -}); - -const endpointFromResponse = Effect.fn("HostProcess.endpointFromResponse")(function* ( - state: State.Interface, - stackId: string, - port: number, -) { - const stack = yield* state.read(stackId); - if (stack === undefined) return yield* error("connect", "Stack is not registered"); const client = yield* HttpClient.HttpClient; + const stale = (cause: unknown) => error("connect", cause, "owner-starting"); const response = yield* client - .execute(HttpClientRequest.get(`http://127.0.0.1:${port}/identity`)) + .execute( + HttpClientRequest.get(`http://127.0.0.1:${owner.port}/identity`).pipe( + HttpClientRequest.bearerToken(owner.secret), + ), + ) .pipe( Effect.timeoutOrElse({ duration: Duration.seconds(2), @@ -119,42 +195,136 @@ const endpointFromResponse = Effect.fn("HostProcess.endpointFromResponse")(funct Effect.fail( error( "connect", - "Timed out connecting to the host control listener", + "Timed out connecting to the owner control endpoint", "connection-failure", ), ), }), - Effect.mapError((cause) => - cause instanceof HostProcessError ? cause : error("connect", cause), - ), + Effect.mapError((cause) => (cause instanceof HostProcessError ? cause : stale(cause))), ); - yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.mapError((cause) => error("connect", cause)), - ); + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.mapError(stale)); const remote = yield* HttpClientResponse.schemaBodyJson(HostEndpoint)(response).pipe( - Effect.mapError((cause) => error("connect", cause)), + Effect.mapError(stale), ); if ( - remote.port !== port || - remote.stackId !== stackId || + remote.port !== owner.port || + remote.pid !== owner.pid || + remote.stackId !== stack.id || remote.identity.projectRoot !== stack.identity.projectRoot || remote.identity.branchContext !== stack.identity.branchContext || remote.identity.stackName !== stack.identity.stackName ) - return yield* error("connect", "Control listener belongs to another stack"); - return remote; + return yield* stale("The control endpoint belongs to another process"); + return { endpoint: remote, secret: owner.secret } satisfies HostAccess; }); +const registered = (state: StateInterface, stackId: string) => + state + .read(stackId) + .pipe( + Effect.flatMap((stack) => + stack === undefined + ? Effect.fail(error("connect", "Stack is not registered", "unregistered")) + : Effect.succeed(stack), + ), + ); + +/** Reads the live owner's endpoint without waiting; dead stacks cost no network round trip. */ +export const observeHost = Effect.fn("HostProcess.observeHost")(function* ( + state: StateInterface, + stack: SavedStack, +): Effect.fn.Return { + return yield* Effect.gen(function* () { + if (!(yield* state.leased(stack.id))) return undefined; + const holder = yield* state.readHolder(stack.id); + if (holder?.role !== "owner") return undefined; + return (yield* identityOf(stack, holder)).endpoint; + }).pipe(Effect.orElseSucceed(() => undefined)); +}); + +export interface ConnectOptions { + /** Accepts an owner of another release, for the release-stable shutdown endpoint. */ + readonly anyRelease?: boolean; +} + +/** + * Resolves the owner holding the stack's lease, waiting while it publishes its endpoint. Fails + * with `not-running` when no process holds the lease and `sweeping` while a sweeper holds it. + */ export const connectHost = Effect.fn("HostProcess.connectHost")(function* ( - state: State.Interface, + state: StateInterface, stackId: string, -): Effect.fn.Return { - const stack = yield* state.read(stackId); - if (stack === undefined) return yield* error("connect", "Stack is not registered"); - const claim = stack.ports.find((entry) => entry.key === "control"); - if (claim === undefined) - return yield* error("connect", "Stack has no control listener", "missing-control-listener"); - return yield* endpointFromResponse(state, stackId, claim.port); + options: ConnectOptions = {}, +): Effect.fn.Return< + HostAccess, + HostProcessError | StateError, + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path | Crypto.Crypto +> { + const stack = yield* registered(state, stackId); + const access = yield* Effect.gen(function* () { + if (!(yield* state.leased(stackId))) + return yield* error("connect", "Stack owner is not running", "not-running"); + const holder = yield* state.readHolder(stackId); + if (holder === undefined) + return yield* error( + "connect", + "Stack owner has not published its endpoint", + "owner-starting", + ); + if (holder.role === "sweeper") + return yield* error("connect", "Another owner is sweeping this stack", "sweeping"); + return yield* identityOf(stack, holder); + }).pipe( + Effect.retry({ + schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), + while: hasReason("owner-starting"), + }), + Effect.mapError((failure) => + hasReason("owner-starting")(failure) + ? error( + "connect", + `Stack owner did not become reachable: ${failure.message} (owner log: ${state.ownerLog(stackId)})`, + ) + : failure, + ), + ); + const release = yield* currentRelease; + if (options.anyRelease !== true && access.endpoint.release !== release) + return yield* error( + "connect", + `Stack ${stackId} is served by release ${access.endpoint.release}, but this client is release ${release}; stop or destroy the stack (both work across releases) and retry`, + "release-mismatch", + ); + return access; +}); + +/** Requests shutdown through the release-stable endpoint; `Some` carries the owner's refusal. */ +export const shutdownHost = Effect.fn("HostProcess.shutdownHost")(function* ( + access: HostAccess, + destroy: boolean, +): Effect.fn.Return, HostProcessError, HttpClient.HttpClient> { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.post(`http://127.0.0.1:${access.endpoint.port}/shutdown`).pipe( + HttpClientRequest.bearerToken(access.secret), + HttpClientRequest.bodyJsonUnsafe({ destroy }), + ), + ) + .pipe( + Effect.mapError((cause) => + error( + "shutdown", + `Owner response unavailable; the shutdown outcome is uncertain: ${cause.message}`, + ), + ), + ); + if (response.status >= 200 && response.status < 300) return Option.none(); + return Option.some( + yield* HttpClientResponse.schemaBodyJson(ShutdownFailure)(response).pipe( + Effect.orElseSucceed(() => ({ message: `Owner rejected shutdown (${response.status})` })), + ), + ); }); export interface LaunchOptions { @@ -162,97 +332,242 @@ export interface LaunchOptions { readonly cacheRoot: string; readonly stackId: string; readonly entrypoint?: string; + /** Registers this definition once the spawned owner holds the lease. */ + readonly register?: SavedStack; + /** Ties a spawned owner to the enclosing scope, whose closure destroys the stack. */ + readonly lifeline?: boolean; } const readyLine = Schema.Union([ - Schema.Struct({ type: Schema.Literal("ready"), endpoint: HostEndpoint }), + Schema.Struct({ type: Schema.Literal("ready"), endpoint: HostEndpoint, secret: Schema.String }), Schema.Struct({ type: Schema.Literal("error"), message: Schema.String, - reason: Schema.optionalKey(Schema.Literals(["bind-conflict", "runtime-unavailable"])), + reason: Schema.optionalKey(Schema.Literals(["lease-held", "exists", "runtime-unavailable"])), }), ]); -const spawnDetached = Effect.fn("HostProcess.spawnDetached")(function* ( + +const exited = (child: ChildProcess) => + child.exitCode !== null || child.signalCode !== null || child.pid === undefined; +const awaitExit = (child: ChildProcess) => + Effect.callback((resume) => { + if (exited(child)) { + resume(Effect.void); + return Effect.void; + } + const done = () => resume(Effect.void); + child.once("exit", done); + child.once("error", done); + return Effect.sync(() => { + child.off("exit", done); + child.off("error", done); + }); + }); +const signal = (child: ChildProcess, name: NodeJS.Signals) => + Effect.sync(() => { + if (exited(child) || child.pid === undefined) return; + try { + if (process.platform === "win32") child.kill(name); + else process.kill(-child.pid, name); + } catch { + child.kill(name); + } + }); +const terminate = (child: ChildProcess) => + signal(child, "SIGTERM").pipe( + Effect.andThen(awaitExit(child)), + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => signal(child, "SIGKILL").pipe(Effect.andThen(awaitExit(child))), + }), + ); + +/** Ends the lifeline and waits for the owner to finish destroying the stack. */ +const closeLifeline = (child: ChildProcess) => + Effect.sync(() => child.stdin?.end()).pipe( + Effect.andThen(awaitExit(child)), + Effect.timeoutOrElse({ + duration: "2 minutes", + orElse: () => Effect.logWarning(`Stack owner ${child.pid} is still destroying its stack`), + }), + ); + +/** Reads the end of the owner log through the spawner's descriptor, which outlives its unlink. */ +const logTail = (descriptor: number) => { + try { + const size = fstatSync(descriptor).size; + const length = Math.min(size, 4096); + const bytes = Buffer.alloc(length); + readSync(descriptor, bytes, 0, length, size - length); + const lines = bytes.toString("utf8").trimEnd().split("\n").slice(-20); + return lines.join("\n"); + } catch { + return ""; + } +}; + +type Spawned = + | { readonly _tag: "Ready"; readonly access: HostAccess } + | { readonly _tag: "LeaseHeld" }; + +const spawnOwner = Effect.fn("HostProcess.spawnOwner")(function* ( + state: StateInterface, options: LaunchOptions, entrypoint: string, -) { - return yield* Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner - .spawn( - ChildProcess.make( - process.execPath, - [entrypoint, options.stateRoot, options.cacheRoot, options.stackId], - { - cwd: process.cwd(), - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - additionalFds: { fd3: { type: "output" } }, - forceKillAfter: "2 seconds", - }, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const log = state.ownerLog(options.stackId); + yield* fs + .makeDirectory(path.dirname(log), { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => error("startup", cause))); + const register = + options.register === undefined + ? [] + : [ + yield* Schema.encodeEffect(Schema.fromJsonString(SavedStack))(options.register).pipe( + Effect.mapError((cause) => error("startup", cause)), ), - ) - .pipe(Effect.mapError((cause) => error("startup", cause))); - const readiness = child.getOutputFd(3).pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(error("startup", "Host readiness stream closed")), - onSome: Effect.succeed, + ]; + return yield* Effect.acquireUseRelease( + Effect.try({ + try: () => openSync(log, "a+", 0o600), + catch: (cause) => error("startup", `Cannot open the owner log ${log}: ${String(cause)}`), + }), + (descriptor) => + Effect.gen(function* () { + const failure = (cause: unknown, reason?: HostFailureReason) => { + const tail = logTail(descriptor); + return error( + "startup", + `Stack owner failed to start: ${error("startup", cause).message} (owner log: ${log})${tail.length === 0 ? "" : `\n${tail}`}`, + reason, + ); + }; + const spawnFailed = yield* Deferred.make(); + return yield* Effect.acquireUseRelease( + Effect.try({ + try: () => { + const started = spawn( + process.execPath, + [entrypoint, options.stateRoot, options.cacheRoot, options.stackId, ...register], + { + cwd: process.cwd(), + detached: true, + windowsHide: true, + stdio: [ + options.lifeline === true ? "pipe" : "ignore", + descriptor, + descriptor, + "pipe", + ], + }, + ); + started.on("error", (cause) => { + Deferred.doneUnsafe(spawnFailed, Exit.fail(failure(cause))); + }); + return started; + }, + catch: failure, }), - ), - Effect.mapError((cause) => error("startup", cause)), - ); - return yield* readiness.pipe( - Effect.timeout(Duration.seconds(30)), - Effect.mapError((cause) => error("startup", cause)), - Effect.flatMap((line) => - Schema.decodeEffect(Schema.fromJsonString(readyLine))(line).pipe( - Effect.mapError((cause) => error("startup", cause)), - Effect.flatMap((decoded) => { - if (decoded.type === "error") - return Effect.fail(error("startup", decoded.message, decoded.reason)); - if (decoded.endpoint.stackId !== options.stackId) - return Effect.fail( - error("startup", "Host readiness identity does not match the requested stack"), - ); - return child.unref.pipe( - Effect.mapError((cause) => error("startup", cause)), - Effect.as(decoded.endpoint), + (child) => + Effect.gen(function* () { + const readiness = child.stdio[3]; + const line = yield* ( + readiness === null || readiness === undefined || !("read" in readiness) + ? Effect.fail(failure("Owner readiness descriptor is unavailable")) + : NodeStream.fromReadable({ evaluate: () => readiness, onError: failure }).pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(failure("Owner exited before reporting readiness")), + onSome: Effect.succeed, + }), + ), + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(failure("Timed out waiting for owner readiness")), + }), + ) + ).pipe( + Effect.raceFirst(Deferred.await(spawnFailed)), + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(readyLine))(text).pipe( + Effect.mapError(failure), + ), + ), + Effect.ensuring(Effect.sync(() => readiness?.destroy())), ); + if (line.type === "error") { + yield* awaitExit(child).pipe(Effect.timeout("5 seconds"), Effect.ignore); + if (line.reason === "lease-held" && options.register === undefined) + return { _tag: "LeaseHeld" } as const; + return yield* line.reason === "lease-held" || line.reason === "exists" + ? error("startup", "Stack already exists; use open") + : failure(line.message, line.reason); + } + if (line.endpoint.stackId !== options.stackId) + return yield* failure( + "Owner readiness identity does not match the requested stack", + ); + child.unref(); + if (options.lifeline === true) { + const stdin = child.stdin; + // Ending the lifeline of an owner that already exited reports EPIPE, which changes nothing. + stdin?.on("error", () => undefined); + if ( + stdin !== null && + Predicate.hasProperty(stdin, "unref") && + typeof stdin.unref === "function" + ) + stdin.unref(); + yield* Effect.addFinalizer(() => closeLifeline(child)); + } + return { + _tag: "Ready", + access: { endpoint: line.endpoint, secret: line.secret }, + } as const; }), - ), - ), - ); - }), + (child, exit) => (Exit.isSuccess(exit) ? Effect.void : terminate(child)), + ); + }), + (descriptor) => Effect.sync(() => closeSync(descriptor)), ); }); -const causeCode = (cause: unknown): string | undefined => { - if (typeof cause !== "object" || cause === null) return undefined; - if ("code" in cause && typeof cause.code === "string") return cause.code; - if ("cause" in cause) return causeCode(cause.cause); - return undefined; -}; -const isConnectionFailure = (failure: HostProcessError) => { - const code = causeCode(failure.cause); - return ( - failure.reason === "connection-failure" || - code === "ECONNREFUSED" || - code === "ConnectionRefused" || - code === "ECONNRESET" || - code === "ETIMEDOUT" || - code === "UND_ERR_CONNECT_TIMEOUT" +/** Connects to the stack's live owner, or spawns one and attaches to whichever owner wins the lease. */ +export const launchHost = Effect.fn("HostProcess.launchHost")(function* ( + state: StateInterface, + options: LaunchOptions, +): Effect.fn.Return< + HostAccess, + HostProcessError | StateError, + Scope.Scope | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path | Crypto.Crypto +> { + const entrypoint = options.entrypoint ?? hostEntrypointFor(import.meta.url); + const attempt = Effect.gen(function* () { + if (options.register === undefined) { + const existing = yield* connectHost(state, options.stackId).pipe( + Effect.map(Option.some), + Effect.catchIf(hasReason("not-running"), () => Effect.succeed(Option.none())), + ); + if (Option.isSome(existing)) return existing.value; + } + const spawned = yield* spawnOwner(state, options, entrypoint); + return spawned._tag === "Ready" ? spawned.access : yield* connectHost(state, options.stackId); + }); + // A sweeper holds a dead stack's lease for a bounded time; a displaced spawn waits it out. + return yield* attempt.pipe( + Effect.retry({ + schedule: Schedule.spaced("100 millis").pipe( + Schedule.upTo({ duration: Duration.sum(sweepTimeout, Duration.seconds(10)) }), + ), + while: hasReason("not-running", "sweeping"), + }), ); -}; -const isBindConflict = (failure: HostProcessError) => { - const code = causeCode(failure.cause); - return failure.reason === "bind-conflict" || code === "EADDRINUSE"; -}; +}); export type OwnerExitProbeResult = | { readonly state: "absent" } @@ -318,35 +633,6 @@ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(functi ); }); -export const launchHost = Effect.fn("HostProcess.launchHost")(function* ( - state: State.Interface, - options: LaunchOptions, -): Effect.fn.Return< - HostEndpoint, - HostProcessError | State.StateError, - Scope.Scope | HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner -> { - const existing = yield* connectHost(state, options.stackId).pipe( - Effect.map(Option.some), - Effect.catchTag("HostProcessError", (failure) => - failure.reason === "missing-control-listener" || isConnectionFailure(failure) - ? Effect.succeed(Option.none()) - : Effect.fail(failure), - ), - ); - if (Option.isSome(existing)) return existing.value; - return yield* spawnDetached( - options, - options.entrypoint ?? hostEntrypointFor(import.meta.url), - ).pipe( - Effect.catchTag("HostProcessError", (failure) => - isBindConflict(failure) - ? connectHost(state, options.stackId).pipe(Effect.mapError(() => failure)) - : Effect.fail(failure), - ), - ); -}); - const hostEntrypointFor = (moduleUrl: string): string => { if (isBunVirtualPath(moduleUrl)) return HOST_PROCESS_DISPATCH_SENTINEL; const sourceEntrypoint = fileURLToPath(new URL("./internal/host-process.ts", moduleUrl)); diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index 6d07416b0e..6d2c97049c 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -26,6 +26,7 @@ const stack = (id: string, port: number | "auto") => ({ id, identity: { projectRoot: "/tmp", branchContext: "test", stackName: id }, runtime: "native" as const, + lifetime: "detached" as const, instances: [], composition: { members: [], dependencies: [] }, ports: port === "auto" ? [] : [{ key: "api", host: "127.0.0.1", port }], diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 22316cb45c..120b8a0697 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -26,6 +26,7 @@ const initial = (id: string): SavedStack => ({ identity: { projectRoot: "/tmp/project", branchContext: "owner-test", stackName: id }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index e7f0e2ddc5..e5c4f9bb0d 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -26,6 +26,7 @@ it.live("retains distinct claims for stopped stacks and rebinds the original pub runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -59,6 +60,7 @@ it.live("reports an occupied saved port without moving its assignment", () => runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -90,6 +92,7 @@ it.live("allocates an auto port outside a contiguous range that refuses to bind" runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -122,6 +125,7 @@ it.live("reassigns the same auto port after its claim is released", () => runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -147,6 +151,7 @@ it.live("names the last bind failure when no public port is available", () => runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -174,6 +179,7 @@ const saveStack = ( runtime: "native", identity: { projectRoot: root, branchContext: `branch-${id}`, stackName: id }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports, }); @@ -350,7 +356,7 @@ it.live("lets a stack bind a port that a running stack claims but does not liste yield* saveStack(state, root, "current"); const ports = yield* makePorts(state); yield* ports.acquire( - { stackId: "running", key: "control", host: "127.0.0.1", port: "auto" }, + { stackId: "running", key: "admin", host: "127.0.0.1", port: "auto" }, bind, ); const restScope = yield* Scope.make(); diff --git a/packages/stack/src/Ports.ts b/packages/stack/src/Ports.ts index 6cb61ce842..d45216416b 100644 --- a/packages/stack/src/Ports.ts +++ b/packages/stack/src/Ports.ts @@ -139,12 +139,9 @@ export const makePorts = (state: State.Interface, platform: NodeJS.Platform = pr !(yield* loopbackOccupied(requested)) ) return; - const message = `Public port ${requested} for ${request.key} at ${request.host}:${requested} is already in use`; return yield* new PortError({ key: request.key, - message: `${message}${yield* claimedBy(yield* state.claims, request.stackId, requested)}`, - // Owners classify an occupied port by this errno, as they do for a failed bind. - cause: Object.assign(new Error(message), { code: "EADDRINUSE" }), + message: `Public port ${requested} for ${request.key} at ${request.host}:${requested} is already in use${yield* claimedBy(yield* state.claims, request.stackId, requested)}`, }); }); diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 83352580b0..9eb9733de8 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -17,6 +17,11 @@ export class StackError extends Schema.TaggedError()("StackError", { operation: Schema.String, message: Schema.String, outcomes: Schema.optionalKey(Schema.Array(Outcome)), + /** + * Why the client could not use an owner: none serves the stack (`owner-unavailable`), or one of + * another release does (`release-mismatch`). + */ + reason: Schema.optionalKey(Schema.Literals(["owner-unavailable", "release-mismatch"])), }) {} /** Maps an owner failure to the RPC error, preserving per-member composition outcomes. */ @@ -143,7 +148,6 @@ export const OwnerRpc = RpcGroup.make( /** The private transport contract; lifecycle admission remains in the owner. */ export const StackRpc = OwnerRpc.add( - Rpc.make("shutdown", { payload: { destroy: Schema.Boolean }, error: StackError }), Rpc.make("runCommand", { payload: RunCommandPayload, success: CommandEvent, diff --git a/packages/stack/src/StackHost.container-shutdown.integration.test.ts b/packages/stack/src/StackHost.container-shutdown.integration.test.ts index d663403761..9c57d139c5 100644 --- a/packages/stack/src/StackHost.container-shutdown.integration.test.ts +++ b/packages/stack/src/StackHost.container-shutdown.integration.test.ts @@ -2,12 +2,11 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as State from "./State.ts"; -import { launchHost, waitForOwnerExit } from "./HostProcess.ts"; -import { StackRpc } from "./Rpc.ts"; +import { hasReason, launchHost, ownerClient, waitForOwnerExit } from "./HostProcess.ts"; import { makeContainerRuntime } from "./runtime/Container.ts"; import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; +import { shutdownOwner } from "../tests/owner.ts"; const helperImage = "public.ecr.aws/docker/library/debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251"; @@ -73,15 +72,6 @@ const createOwnedContainer = (name: string, stackId: string, dataRoot: string) = helperImage, ]); -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); - const startHost = (stateRoot: string, cacheRoot: string, stackId: string, projectRoot: string) => Effect.gen(function* () { const state = yield* stateFor(stateRoot); @@ -92,6 +82,7 @@ const startHost = (stateRoot: string, cacheRoot: string, stackId: string, projec runtime: "docker", identity: { projectRoot, branchContext: "container-shutdown-test", stackName: stackId }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -123,9 +114,16 @@ it.live.skipIf(process.platform === "win32")( let activeB: { readonly pid: number; readonly port: number } | undefined; let stoppedA = true; let stoppedB = true; + // A signalled owner stops its containers, each within a 10 second grace, before exiting; + // no acknowledgement precedes that cleanup, so the post-acknowledgement exit bound repeats. const signalAndWait = (endpoint: { pid: number }, signal: NodeJS.Signals) => Effect.sync(() => process.kill(endpoint.pid, signal)).pipe( - Effect.andThen(waitForOwnerExit(endpoint.pid).pipe(Effect.timeout("15 seconds"))), + Effect.andThen( + waitForOwnerExit(endpoint.pid).pipe( + Effect.retry({ while: hasReason("owner-exit-pending") }), + Effect.timeout("30 seconds"), + ), + ), ); yield* Effect.addFinalizer(() => Effect.gen(function* () { @@ -142,7 +140,8 @@ it.live.skipIf(process.platform === "win32")( stackId, dataA, ); - const endpointA = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const accessA = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const endpointA = accessA.endpoint; activeA = endpointA; stoppedA = false; expect(staleAtStartup.length).toBeGreaterThan(0); @@ -160,7 +159,8 @@ it.live.skipIf(process.platform === "win32")( stackId, dataB, ); - const endpointB = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const accessB = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const endpointB = accessB.endpoint; activeB = endpointB; stoppedB = false; expect(staleForA.length).toBeGreaterThan(0); @@ -171,9 +171,9 @@ it.live.skipIf(process.platform === "win32")( "startup sweep removes B stale container", ).toEqual([]); - const clientA = yield* clientFor(endpointA.port); - const clientB = yield* clientFor(endpointB.port); - const createAndStartDatabase = (client: ReturnType, suffix: string) => + const clientA = yield* ownerClient(accessA); + const clientB = yield* ownerClient(accessB); + const createAndStartDatabase = (client: ReturnType, suffix: string) => client.pipe( Effect.flatMap((rpc) => rpc.createService({ @@ -221,24 +221,22 @@ it.live.skipIf(process.platform === "win32")( expect(yield* containers(stackId, dataB), "SIGINT removes B containers").toEqual([]); expect(yield* (yield* stateFor(rootB)).read(stackId)).toBeDefined(); - const endpointA2 = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const accessA2 = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const endpointA2 = accessA2.endpoint; activeA = endpointA2; stoppedA = false; - yield* clientFor(endpointA2.port).pipe( - Effect.flatMap((rpc) => rpc.shutdown({ destroy: true })), - ); + yield* shutdownOwner(accessA2, true); yield* waitForOwnerExit(endpointA2.pid).pipe(Effect.timeout("15 seconds")); stoppedA = true; activeA = undefined; expect(yield* containers(stackId, dataA), "destroy removes A containers").toEqual([]); expect(yield* (yield* stateFor(rootA)).read(stackId)).toBeUndefined(); - const endpointB2 = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const accessB2 = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const endpointB2 = accessB2.endpoint; activeB = endpointB2; stoppedB = false; - yield* clientFor(endpointB2.port).pipe( - Effect.flatMap((rpc) => rpc.shutdown({ destroy: true })), - ); + yield* shutdownOwner(accessB2, true); yield* waitForOwnerExit(endpointB2.pid).pipe(Effect.timeout("15 seconds")); stoppedB = true; activeB = undefined; diff --git a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts index 99f9b6c9d3..5e893aaa75 100644 --- a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts +++ b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts @@ -2,28 +2,17 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Context, Deferred, Effect, FileSystem, Layer, Sink, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { StackRpc } from "./Rpc.ts"; import * as State from "./State.ts"; -import { acquireHost } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import * as CommandRunner from "./host/CommandRunner.ts"; -import { makeRuntime } from "./StackHost.ts"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { bindControl, makeRuntime } from "./StackHost.ts"; +import { shutdownOwner } from "../tests/owner.ts"; const stateFor = (root: string) => Layer.build(State.layer({ root })).pipe( Effect.map((context) => Context.get(context, State.Service)), ); -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); - it.live("retains saved state when destroy sweep fails and retries after engine recovery", () => Effect.scoped( Effect.gen(function* () { @@ -35,6 +24,7 @@ it.live("retains saved state when destroy sweep fails and retries after engine r runtime: "docker" as const, identity: { projectRoot: root, branchContext: "main", stackName: "sweep-retry" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -82,7 +72,7 @@ it.live("retains saved state when destroy sweep fails and retries after engine r Effect.map((context) => Context.get(context, Owner.Service)), ); const owner = baseOwner; - const acquired = yield* acquireHost(state, saved.id); + const acquired = yield* bindControl(); const runnerLayer = yield* Layer.build( CommandRunner.layer({ stackId: saved.id, @@ -94,10 +84,14 @@ it.live("retains saved state when destroy sweep fails and retries after engine r const runtime = yield* makeRuntime( owner, { - stackId: saved.id, - identity: saved.identity, - pid: process.pid, - port: acquired.port, + endpoint: { + stackId: saved.id, + identity: saved.identity, + pid: process.pid, + port: acquired.port, + release: "test", + }, + secret: "test-secret", }, acquired.server, acquired.closeConnections, @@ -108,9 +102,7 @@ it.live("retains saved state when destroy sweep fails and retries after engine r ), ); yield* runtime.serve; - const client = yield* clientFor(runtime.endpoint.port); - const failure = yield* client.shutdown({ destroy: true }).pipe(Effect.flip); - expect("operation" in failure ? failure.operation : undefined).toBe("shutdown"); + const failure = yield* shutdownOwner(runtime.access, true).pipe(Effect.flip); expect(listCalls, failure.message).toBe(3); expect(yield* (yield* stateFor(`${root}/state`)).read(saved.id)).toBeDefined(); yield* Deferred.await(runtime.exit).pipe(Effect.timeout("10 seconds")); diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 59fbc2212f..34afa2bfe3 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -16,15 +16,17 @@ import { Stream, } from "effect"; import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/unstable/rpc"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClient from "effect/unstable/http/HttpClient"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration observes exact listener closure. import * as Net from "node:net"; -import { acquireHost, launchHost } from "./HostProcess.ts"; +import { launchHost, ownerAuthorization, ownerClient, type HostAccess } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import { OrchestratorError } from "./Orchestrator.ts"; -import { CommandEvent, StackError, StackRpc } from "./Rpc.ts"; +import { CommandEvent, StackError } from "./Rpc.ts"; import * as State from "./State.ts"; -import { makeRuntime } from "./StackHost.ts"; +import { bindControl, makeRuntime } from "./StackHost.ts"; +import { shutdownOwner } from "../tests/owner.ts"; import { postgres } from "./Commands.ts"; import * as CommandRunner from "./host/CommandRunner.ts"; @@ -54,16 +56,7 @@ const ownerFor = (options: { }); }; -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); - -const permissiveCommandClientFor = (port: number) => +const permissiveCommandClientFor = (access: HostAccess) => RpcClient.make( RpcGroup.make( Rpc.make("runCommand", { @@ -75,8 +68,16 @@ const permissiveCommandClientFor = (port: number) => ), ).pipe( Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( + RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${access.endpoint.port}/rpc` }).pipe( Layer.provide(RpcSerialization.layerNdjson), + Layer.provide( + Layer.effect( + HttpClient.HttpClient, + HttpClient.HttpClient.pipe( + Effect.map(HttpClient.mapRequest(HttpClientRequest.bearerToken(access.secret))), + ), + ), + ), ), ), ); @@ -131,7 +132,7 @@ const inProcessRuntime = ( root: string, ) => Effect.gen(function* () { - const acquired = yield* acquireHost(state, "stack"); + const acquired = yield* bindControl(); const toolContext = yield* Layer.build( CommandRunner.layer({ stackId: "stack", @@ -143,10 +144,14 @@ const inProcessRuntime = ( const runtime = yield* makeRuntime( owner, { - stackId: "stack", - identity: { projectRoot: root, branchContext: "main", stackName: "host" }, - pid: process.pid, - port: acquired.port, + endpoint: { + stackId: "stack", + identity: { projectRoot: root, branchContext: "main", stackName: "host" }, + pid: process.pid, + port: acquired.port, + release: "test", + }, + secret: "test-secret", }, acquired.server, acquired.closeConnections, @@ -211,6 +216,7 @@ it.live("preserves composition outcomes over RPC", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-outcomes" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -222,7 +228,7 @@ it.live("preserves composition outcomes over RPC", () => cacheRoot: "/tmp/supabase-stack-artifacts", }); const { runtime } = yield* inProcessRuntime(owner, state, root); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const port = yield* occupiedPort; const lazy = yield* client.createService({ service: "mail", @@ -250,7 +256,7 @@ it.live("preserves composition outcomes over RPC", () => { id: lazy.id, succeeded: true }, { id: blocked.id, succeeded: false, error: expect.stringContaining(String(port)) }, ]); - yield* client.shutdown({ destroy: true }); + yield* shutdownOwner(runtime.access, true); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -266,6 +272,7 @@ it.live("keeps serving when namespace shutdown fails", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-drain-failure" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -286,9 +293,9 @@ it.live("keeps serving when namespace shutdown fails", () => }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); - const failure = yield* client.shutdown({ destroy: false }).pipe(Effect.flip); - expect("operation" in failure).toBe(true); + const client = yield* ownerClient(runtime.access); + const failure = yield* shutdownOwner(runtime.access, false).pipe(Effect.flip); + expect(failure.message).toContain("cleanup failed"); yield* client.configureComposition({ members: [], dependencies: [] }); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), @@ -305,6 +312,7 @@ it.live("reports destroy and fallback stop failures together", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-destroy-failure" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -354,8 +362,7 @@ it.live("reports destroy and fallback stop failures together", () => }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); - const failure = yield* client.shutdown({ destroy: true }).pipe(Effect.flip); + const failure = yield* shutdownOwner(runtime.access, true).pipe(Effect.flip); expect(failure.message).toContain("Namespace destroy had failures"); expect(failure.message).toContain("database-destroy:"); expect(failure.message).toContain("data removal refused"); @@ -395,6 +402,7 @@ it.live("rejects destroy while stop is in flight", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-stop-join" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -440,6 +448,7 @@ it.live("retains ownership when namespace shutdown defects and retries cleanup", runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-drain-defect" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -462,14 +471,15 @@ it.live("retains ownership when namespace shutdown defects and retries cleanup", }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const failure = yield* runtime.shutdown(false).pipe(Effect.exit); expect(Exit.isFailure(failure)).toBe(true); if (Exit.isFailure(failure)) expect(Cause.pretty(failure.cause)).toContain("cleanup failed"); const http = yield* HttpClient.HttpClient; - expect((yield* http.get(`http://127.0.0.1:${runtime.endpoint.port}/identity`)).status).toBe( - 200, - ); + const identity = yield* http.get(`http://127.0.0.1:${runtime.endpoint.port}/identity`, { + headers: { authorization: ownerAuthorization(runtime.access.secret) }, + }); + expect(identity.status).toBe(200); expect(yield* owner.getServing).toBe(true); yield* client.configureComposition({ members: [], dependencies: [] }); yield* runtime.shutdown(false); @@ -490,19 +500,25 @@ it.live( runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); - const endpoint = yield* launchHost(state, { + const access = yield* launchHost(state, { stateRoot: `${root}/state`, cacheRoot: "/tmp/supabase-stack-artifacts", stackId: "stack", }); + const { endpoint } = access; const http = yield* HttpClient.HttpClient; - const identity = yield* http.get(`http://127.0.0.1:${endpoint.port}/identity`); + const identityUrl = `http://127.0.0.1:${endpoint.port}/identity`; + expect((yield* http.get(identityUrl)).status, "the secret is required").toBe(401); + const identity = yield* http.get(identityUrl, { + headers: { authorization: ownerAuthorization(access.secret) }, + }); expect(identity.status).toBe(200); - const client = yield* clientFor(endpoint.port); - const permissiveClient = yield* permissiveCommandClientFor(endpoint.port); + const client = yield* ownerClient(access); + const permissiveClient = yield* permissiveCommandClientFor(access); for (const override of ["args", "env", "pgProve", "stdin"] as const) { const rejected = yield* permissiveClient .runCommand({ @@ -534,7 +550,7 @@ it.live( yield* Effect.addFinalizer(() => Ref.get(stopped).pipe( Effect.flatMap((value) => - value ? Effect.void : client.shutdown({ destroy: true }).pipe(Effect.ignore), + value ? Effect.void : shutdownOwner(access, true).pipe(Effect.ignore), ), ), ); @@ -632,7 +648,7 @@ it.live( ); yield* Deferred.await(ready); expect(idleSocket.destroyed).toBe(false); - yield* client.shutdown({ destroy: false }); + yield* shutdownOwner(access, false); yield* awaitClosed(idleSocket).pipe( Effect.timeoutOrElse({ duration: "5 seconds", @@ -674,6 +690,7 @@ it.live("finishes detached shutdown after the caller disconnects", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host-abort" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -683,6 +700,7 @@ it.live("finishes detached shutdown after the caller disconnects", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }, @@ -704,8 +722,7 @@ it.live("finishes detached shutdown after the caller disconnects", () => }; const { runtime } = yield* inProcessRuntime(delayedOwner, state, root); yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); - const client = yield* clientFor(runtime.endpoint.port); - const shutdown = yield* Effect.forkScoped(client.shutdown({ destroy: false })); + const shutdown = yield* Effect.forkScoped(shutdownOwner(runtime.access, false)); yield* Deferred.await(entered); yield* Fiber.interrupt(shutdown); yield* Deferred.succeed(allow, undefined); @@ -725,6 +742,7 @@ it.live("withdraws a command waiting for its prerequisite", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-command-abort" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -756,7 +774,7 @@ it.live("withdraws a command waiting for its prerequisite", () => Effect.andThen(runtime.shutdown(false).pipe(Effect.ignore)), ), ); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const mail = yield* client.createService({ service: "mail", config: {}, @@ -768,7 +786,7 @@ it.live("withdraws a command waiting for its prerequisite", () => yield* Deferred.await(cancelled).pipe(Effect.timeout("5 seconds")); yield* Deferred.succeed(allow, undefined); expect((yield* client.status({ id: mail.id })).lifecycle).toBe("stopped"); - yield* client.shutdown({ destroy: false }); + yield* shutdownOwner(runtime.access, false); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -783,6 +801,7 @@ const disconnectFixture = (prefix: string) => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: prefix }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; @@ -832,7 +851,7 @@ it.live("finishes a service creation after its caller disconnects", () => state, root, ); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const creation = yield* Effect.forkScoped( client.createService({ service: "mail", @@ -852,7 +871,7 @@ it.live("finishes a service creation after its caller disconnects", () => expect(others).toEqual([]); expect((yield* client.status({ id: instance.id })).lifecycle).toBe("stopped"); yield* client.destroyService({ id: instance.id }); - yield* client.shutdown({ destroy: true }); + yield* shutdownOwner(runtime.access, true); yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); expect(yield* state.read(saved.id)).toBeUndefined(); }), @@ -896,7 +915,7 @@ it.live("persists a composition change after its caller disconnects", () => state, root, ); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const mail = yield* client.createService({ service: "mail", config: {}, @@ -913,7 +932,7 @@ it.live("persists a composition change after its caller disconnects", () => yield* client.createService({ service: "mail", config: {}, endpoints: {} }); expect((yield* state.read(saved.id))?.composition).toEqual({ members, dependencies: [] }); - yield* client.shutdown({ destroy: true }); + yield* shutdownOwner(runtime.access, true); yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), @@ -965,7 +984,7 @@ const abandonedComposition = (prefix: string, destroy: boolean) => state, root, ); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const composition = yield* Effect.forkScoped( client.supabaseComposition({ services: [ @@ -985,7 +1004,7 @@ const abandonedComposition = (prefix: string, destroy: boolean) => yield* Deferred.await(persisted); yield* Fiber.interrupt(composition); yield* Deferred.await(interrupted); - const shutdown = yield* Effect.forkScoped(client.shutdown({ destroy })); + const shutdown = yield* Effect.forkScoped(shutdownOwner(runtime.access, destroy)); yield* Deferred.await(draining); yield* Deferred.succeed(allow, undefined); yield* Fiber.join(shutdown); diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 361f6513b4..53abcb01eb 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -1,8 +1,15 @@ -import { NodeHttpServerRequest, NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { + NodeHttpClient, + NodeHttpServer, + NodeHttpServerRequest, + NodeServices, +} from "@effect/platform-node"; import { Context, Cause, + Crypto, Data, + DateTime, Deferred, Effect, Exit, @@ -21,10 +28,20 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; -import { acquireHost, HostEndpoint } from "./HostProcess.ts"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.make requires a native server factory. +import * as Http from "node:http"; +import { + currentRelease, + authorizes, + ShutdownRequest, + type HostAccess, + type HostEndpoint, + type ShutdownFailure, +} from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; import * as State from "./State.ts"; +import { sweepOrphans } from "./Sweep.ts"; import { makeCommandAttachments } from "./host/CommandAttachments.ts"; import * as CommandRunner from "./host/CommandRunner.ts"; @@ -32,14 +49,17 @@ export interface StackHostOptions { readonly stateRoot: string; readonly cacheRoot: string; readonly stackId: string; - readonly onReady?: (endpoint: HostEndpoint) => Effect.Effect; + /** Registers this definition once the owner holds the lease; the stack must not exist. */ + readonly register?: State.SavedStack; + readonly release?: string; + readonly onReady?: (access: HostAccess) => Effect.Effect; } export class StackHostError extends Data.TaggedError("StackHostError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; - readonly reason?: "runtime-unavailable"; + readonly reason?: "lease-held" | "exists" | "runtime-unavailable"; }> {} const hostError = (operation: string, cause: unknown, reason?: "runtime-unavailable") => @@ -50,6 +70,50 @@ const hostError = (operation: string, cause: unknown, reason?: "runtime-unavaila ...(reason === undefined ? {} : { reason }), }); +/** Binds the owner's loopback control listener on an OS-assigned port. */ +export const bindControl = Effect.fn("StackHost.bindControl")(function* () { + let rawServer: Http.Server | undefined; + const server = yield* NodeHttpServer.make( + () => { + rawServer = Http.createServer(); + return rawServer; + }, + { host: "127.0.0.1", port: 0 }, + ).pipe(Effect.mapError((cause) => hostError("control", cause))); + if (server.address._tag !== "TcpAddress") + return yield* hostError("control", "Control listener has no TCP address"); + return { + port: server.address.port, + server, + closeConnections: Effect.sync(() => { + rawServer?.closeAllConnections(); + rawServer?.closeIdleConnections(); + }), + }; +}); + +const shutdownFailure = (cause: unknown): ShutdownFailure => { + const failure = stackError("shutdown", cause); + return { + message: failure.message, + ...(failure.outcomes === undefined ? {} : { outcomes: failure.outcomes }), + }; +}; + +const creatorGone = Effect.callback((resume) => { + const done = () => resume(Effect.void); + process.stdin.once("end", done); + process.stdin.once("close", done); + process.stdin.once("error", done); + process.stdin.resume(); + return Effect.sync(() => { + process.stdin.off("end", done); + process.stdin.off("close", done); + process.stdin.off("error", done); + process.stdin.pause(); + }); +}); + const isOpen = (value: boolean): Effect.Effect => value ? Effect.void @@ -84,6 +148,7 @@ const responseClosed = (response: ReturnType; readonly closeConnections: Effect.Effect; readonly shutdown: ( @@ -96,7 +161,7 @@ export interface StackHostRuntime { export const makeRuntime = Effect.fn("StackHost.makeRuntime")( ( owner: Owner.Interface, - endpoint: HostEndpoint, + access: HostAccess, server: HttpServer.HttpServer["Service"], closeConnections: Effect.Effect, ): Effect.Effect => @@ -261,17 +326,6 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( ); const handlers = StackRpc.of({ ...owner.handlers, - shutdown: ({ destroy }: { readonly destroy: boolean }) => - Effect.gen(function* () { - const request = yield* Effect.serviceOption(HttpServerRequest.HttpServerRequest); - yield* Option.match(request, { - onNone: () => - Effect.fail( - new StackError({ operation: "shutdown", message: "Request context is missing" }), - ), - onSome: (value) => shutdown(destroy, NodeHttpServerRequest.toServerResponse(value)), - }); - }).pipe(Effect.mapError((cause) => stackError("shutdown", cause))), runCommand: (input: RunCommandPayload) => attachments.run(input), commandInput: ({ attachmentId, @@ -290,8 +344,28 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( HttpServerRequest.HttpServerRequest | Scope.Scope > = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; + if (!authorizes(request.headers.authorization, access.secret)) + return HttpServerResponse.empty({ status: 401 }); if (request.method === "GET" && request.url === "/identity") { - return HttpServerResponse.jsonUnsafe(endpoint); + return HttpServerResponse.jsonUnsafe(access.endpoint); + } + if (request.method === "POST" && request.url === "/shutdown") { + const body = yield* HttpServerRequest.schemaBodyJson(ShutdownRequest).pipe(Effect.option); + if (Option.isNone(body)) return HttpServerResponse.empty({ status: 400 }); + const result = yield* shutdown( + body.value.destroy, + NodeHttpServerRequest.toServerResponse(request), + ).pipe(Effect.exit); + return Exit.isSuccess(result) + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.jsonUnsafe( + shutdownFailure( + Option.getOrElse(Cause.findErrorOption(result.cause), () => + Cause.pretty(result.cause), + ), + ), + { status: 500 }, + ); } if (request.method === "POST" && request.url.startsWith("/rpc")) return yield* rpc.pipe(Effect.interruptible); @@ -299,30 +373,55 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( }); const serve: Effect.Effect = server.serve(application); - return { endpoint, serve, shutdown, closeConnections, exit }; + return { endpoint: access.endpoint, access, serve, shutdown, closeConnections, exit }; }), ); +type HostEvent = "SIGTERM" | "SIGINT" | "creator-gone"; + export const runStackHost = Effect.fn("StackHost.run")( (options: StackHostOptions): Effect.Effect => Effect.scoped( Effect.gen(function* () { - const signals = yield* Queue.bounded<"SIGTERM" | "SIGINT">(8); + const events = yield* Queue.bounded(8); yield* Effect.forkScoped( Effect.forever( requestSignal().pipe( - Effect.flatMap((signal) => Queue.offer(signals, signal).pipe(Effect.asVoid)), + Effect.flatMap((signal) => Queue.offer(events, signal).pipe(Effect.asVoid)), ), ), ); + const stateContext = yield* Layer.build(State.layer({ root: options.stateRoot })); + const state = Context.get(stateContext, State.Service); + const id = options.stackId; + // The lease is released last, after every owned process and listener has closed. + if (!(yield* state.lease(id))) + return yield* new StackHostError({ + operation: "lease", + message: `Another owner holds the lease of stack ${id}`, + reason: "lease-held", + }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.truncate(state.ownerLog(id)).pipe(Effect.ignore); + yield* state.retractHolder(id); + const register = options.register; + if (register !== undefined) + yield* state.withLock( + Effect.gen(function* () { + if ((yield* state.read(id)) !== undefined) + return yield* new StackHostError({ + operation: "register", + message: "Stack already exists; use open", + reason: "exists", + }); + yield* state.save(register); + }), + ); const started = yield* Effect.gen(function* () { - const stateContext = yield* Layer.build(State.layer({ root: options.stateRoot })); - const state = Context.get(stateContext, State.Service); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const saved = yield* state.read(options.stackId); + const saved = yield* state.read(id); if (saved === undefined) return yield* hostError("startup", "Stack is not registered"); - const acquired = yield* acquireHost(state, options.stackId); + const control = yield* bindControl(); const dataRootPath = path.join(options.stateRoot, saved.id, "data"); yield* fs.makeDirectory(dataRootPath, { recursive: true }); const dataRoot = yield* fs.realPath(dataRootPath); @@ -355,13 +454,19 @@ export const runStackHost = Effect.fn("StackHost.run")( stackId: saved.id, identity: saved.identity, pid: process.pid, - port: acquired.port, + port: control.port, + release: options.release ?? (yield* currentRelease), }; + const crypto = yield* Crypto.Crypto; + const secret = Array.from(yield* crypto.randomBytes(32), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const access: HostAccess = { endpoint, secret }; const runtime = yield* makeRuntime( owner, - endpoint, - acquired.server, - acquired.closeConnections, + access, + control.server, + control.closeConnections, ).pipe( Effect.provideService( CommandRunner.Service, @@ -369,21 +474,61 @@ export const runStackHost = Effect.fn("StackHost.run")( ), ); yield* runtime.serve; - yield* options.onReady?.(endpoint) ?? Effect.void; + yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); + yield* state.publishHolder(id, { + role: "owner", + secret, + port: endpoint.port, + pid: endpoint.pid, + release: endpoint.release, + lifetime: saved.lifetime, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + if (saved.lifetime === "session") + yield* Effect.forkScoped( + creatorGone.pipe(Effect.andThen(Queue.offer(events, "creator-gone"))), + ); + yield* options.onReady?.(access) ?? Effect.void; + yield* Effect.forkScoped( + sweepOrphans({ + state, + stateRoot: options.stateRoot, + cacheRoot: options.cacheRoot, + ownerId: id, + }), + ); return runtime; }).pipe( Effect.raceFirst( - Queue.take(signals).pipe( - Effect.flatMap((signal) => hostError("startup", `Received ${signal} during startup`)), + Queue.take(events).pipe( + Effect.flatMap((event) => + hostError( + "startup", + event === "creator-gone" + ? "The session stack's creator exited during startup" + : `Received ${event} during startup`, + ), + ), ), ), + // A stack registered by this owner must not outlive a failed start. + Effect.onError(() => + register === undefined ? Effect.void : state.remove(id).pipe(Effect.ignore), + ), ); while (true) { const event = yield* Deferred.await(started.exit).pipe( Effect.map(() => "done" as const), - Effect.raceFirst(Queue.take(signals).pipe(Effect.map(() => "signal" as const))), + Effect.raceFirst(Queue.take(events)), ); if (event === "done") break; + if (event === "creator-gone") { + yield* started.shutdown(true).pipe( + Effect.tapCause((cause) => Effect.logError("Session stack destroy failed", cause)), + Effect.ignore, + ); + break; + } const shutdownSucceeded = yield* started.shutdown(false).pipe( Effect.tapCause((cause) => Effect.logError("Stack shutdown failed", cause)), Effect.matchCause({ onSuccess: () => true, onFailure: () => false }), diff --git a/packages/stack/src/State.integration.test.ts b/packages/stack/src/State.integration.test.ts index e095892d32..19a1548de3 100644 --- a/packages/stack/src/State.integration.test.ts +++ b/packages/stack/src/State.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { TestClock } from "effect/testing"; import { Context, + DateTime, Deferred, Effect, Exit, @@ -13,6 +14,7 @@ import { PlatformError, Ref, Schema, + Scope, } from "effect"; import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; @@ -31,6 +33,7 @@ const initial: SavedStack = { }, runtime: "docker", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; @@ -562,6 +565,31 @@ describe("durable stack state", () => { ), ); + it.live("reaps write leftovers of dead writers and keeps recent ones on open", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-reap-" }); + const abandoned = path.join(root, ".state-write-abandoned"); + const inProgress = path.join(root, ".state-write-in-progress"); + for (const directory of [abandoned, inProgress]) { + yield* fs.makeDirectory(directory); + yield* fs.writeFileString(path.join(directory, "state.json"), "{}"); + } + const twoDaysAgo = DateTime.toDateUtc(DateTime.subtract(yield* DateTime.now, { days: 2 })); + yield* fs.utimes(abandoned, twoDaysAgo, twoDaysAgo); + + const store = yield* makeTestState(root); + + expect(yield* fs.exists(abandoned)).toBe(false); + expect(yield* fs.exists(path.join(inProgress, "state.json"))).toBe(true); + yield* store.save(initial); + expect(yield* store.read(initial.id)).toEqual(initial); + }), + ), + ); + it.live("removes empty stack parents without deleting unowned data", () => run( Effect.gen(function* () { @@ -692,3 +720,61 @@ describe("durable stack state", () => { ), ); }); + +describe("stack owner lease", () => { + it.live("hands the lease of a removed stack to a waiter on a live lease file", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-lease-handoff-" }); + const holder = yield* makeTestState(root); + const contended = yield* Deferred.make(); + const waiter = Context.get( + yield* Layer.build( + State.layer({ + root, + onLeaseContended: () => Deferred.succeed(contended, undefined).pipe(Effect.asVoid), + }), + ), + State.Service, + ); + const observer = yield* makeTestState(root); + const holderScope = yield* Scope.make(); + expect(yield* holder.lease("gone").pipe(Scope.provide(holderScope))).toBe(true); + + const waiterScope = yield* Scope.make(); + const waiting = yield* waiter + .lease("gone") + .pipe(Scope.provide(waiterScope), Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(contended); + yield* Scope.close(holderScope, Exit.void); + + expect(yield* Fiber.join(waiting)).toBe(true); + expect(yield* observer.leased("gone"), "the path names the file the waiter locked").toBe( + true, + ); + yield* Scope.close(waiterScope, Exit.void); + expect(yield* observer.leased("gone")).toBe(false); + expect(yield* fs.exists(`${root}/gone`), "the last holder removes the directory").toBe( + false, + ); + }), + ), + ); + + it.live("reports a free lease without creating a lease file", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-lease-probe-" }); + const state = yield* makeTestState(root); + yield* state.save(initial); + expect(yield* state.leased(initial.id)).toBe(false); + expect(yield* state.readHolder(initial.id), "a retracted record reads as absent").toBe( + undefined, + ); + expect(yield* fs.exists(`${root}/${initial.id}/owner.lock`)).toBe(false); + }), + ), + ); +}); diff --git a/packages/stack/src/State.process.integration.test.ts b/packages/stack/src/State.process.integration.test.ts index 458911e7ec..7030ce3ef6 100644 --- a/packages/stack/src/State.process.integration.test.ts +++ b/packages/stack/src/State.process.integration.test.ts @@ -70,6 +70,7 @@ for (const compiled of [false, true]) { identity: { projectRoot: root, branchContext: "test", stackName: "lock" }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; diff --git a/packages/stack/src/State.ts b/packages/stack/src/State.ts index e72feec752..440e864641 100644 --- a/packages/stack/src/State.ts +++ b/packages/stack/src/State.ts @@ -1,19 +1,24 @@ import { + Clock, Data, Duration, Effect, + Exit, FileSystem, Context, Layer, + Option, Path, Predicate, Schedule, Schema, + Scope, } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no non-recursive directory removal operation. import { rmdir } from "node:fs/promises"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no OS-owned cross-process lock primitive. import { DatabaseSync } from "node:sqlite"; +import { pathToFileURL } from "node:url"; import { CompositionConfig } from "./Orchestrator.ts"; import { restrictDirectoryToOwner } from "./runtime/postgres-user.ts"; import { ServiceCreation } from "./services/Catalog.ts"; @@ -67,8 +72,13 @@ const PortClaim = Schema.Struct({ }); export interface PortClaim extends Schema.Schema.Type {} +/** A session stack is destroyed when its creator exits; a detached stack outlives it. */ +export const StackLifetime = Schema.Literals(["session", "detached"]); +export type StackLifetime = Schema.Schema.Type; + export const SavedStack = Schema.Struct({ id: SafeId, + lifetime: StackLifetime, identity: Schema.Struct({ projectRoot: Schema.String, branchContext: Schema.String, @@ -90,6 +100,21 @@ export interface SavedStack extends Schema.Schema.Type {} const ClaimsDocument = Schema.Struct({ ports: Schema.Array(PortClaim) }); +/** What the current lease holder publishes: an owner's control endpoint, or a sweeper's marker. */ +const LeaseHolder = Schema.Union([ + Schema.Struct({ + role: Schema.Literal("owner"), + port: PortClaim.fields.port, + pid: Schema.Int, + release: Schema.String, + lifetime: StackLifetime, + startedAt: Schema.String, + secret: Schema.String, + }), + Schema.Struct({ role: Schema.Literal("sweeper"), pid: Schema.Int, startedAt: Schema.String }), +]); +type LeaseHolder = Schema.Schema.Type; + export interface StackClaims { readonly id: string; readonly ports: ReadonlyArray; @@ -113,6 +138,20 @@ export interface Interface { readonly withLock: ( effect: Effect.Effect, ) => Effect.Effect; + /** + * Holds the stack's owner lease for the enclosing scope, or returns `false` while another + * process holds it. Releasing the lease of a removed stack deletes its directory. + */ + readonly lease: (id: string) => Effect.Effect; + /** Reports whether any process currently holds the stack's owner lease. */ + readonly leased: (id: string) => Effect.Effect; + /** Only meaningful while the lease is held; a record left by a dead holder is stale. */ + readonly readHolder: (id: string) => Effect.Effect; + /** Written by the lease holder with owner-only permissions, since it carries the owner secret. */ + readonly publishHolder: (id: string, record: LeaseHolder) => Effect.Effect; + readonly retractHolder: (id: string) => Effect.Effect; + /** The file that receives the stack owner's stdout and stderr. */ + readonly ownerLog: (id: string) => string; } export class Service extends Context.Service()("@supabase/stack/State") {} @@ -149,8 +188,36 @@ interface Options { readonly root: string; readonly platform?: NodeJS.Platform; readonly onInvalidState?: (id: string, error: StateError) => Effect.Effect; + /** Observes a lease request that found the lease held and is waiting for it. */ + readonly onLeaseContended?: (id: string) => Effect.Effect; } +const stateWritePrefix = ".state-write-"; +/** A state write takes milliseconds, so a temporary directory this old belongs to a dead writer. */ +const staleWriteAgeMillis = 24 * 60 * 60 * 1000; + +/** Best-effort removal of temporary write directories a killed writer left behind. */ +const reapStaleWrites = (fs: FileSystem.FileSystem, path: Path.Path, root: string) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const entries = yield* fs.readDirectory(root); + yield* Effect.forEach( + entries.filter((entry) => entry.startsWith(stateWritePrefix)), + (entry) => { + const candidate = path.join(root, entry); + return fs.stat(candidate).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => now - mtime.getTime() > staleWriteAgeMillis) + ? fs.remove(candidate, { recursive: true, force: true }) + : Effect.void, + ), + Effect.ignore, + ); + }, + { discard: true }, + ); + }).pipe(Effect.ignore); + const makeState = ( options: Options, ): Effect.Effect => @@ -166,9 +233,14 @@ const makeState = ( yield* restrictDirectoryToOwner(fs, root).pipe( Effect.mapError((cause) => stateError("root", cause)), ); + yield* reapStaleWrites(fs, path, root); const stackRoot = (id: string) => path.join(root, id); const statePath = (id: string) => path.join(stackRoot(id), "state.json"); + // Only lease code opens a lease file, for the same reason as the registry lock. + const leasePath = (id: string) => path.join(stackRoot(id), "owner.lock"); + const ownerPath = (id: string) => path.join(stackRoot(id), "owner.json"); + const ownerLog = (id: string) => path.join(stackRoot(id), "owner.log"); const publishRetrySchedule = Schedule.exponential("10 millis", 2).pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.millis(100))), @@ -261,84 +333,263 @@ const makeState = ( Effect.map(({ ports }): StackClaims => ({ id, ports })), ); const claims = Effect.fn("State.claims")(() => readEntries(readClaims, () => Effect.void)); + const writeAtomically = (id: string, target: string, serialized: string) => + Effect.gen(function* () { + yield* fs + .makeDirectory(stackRoot(id), { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => stateError("write", cause))); + yield* Effect.acquireUseRelease( + fs + .makeTempDirectory({ directory: root, prefix: stateWritePrefix }) + .pipe(Effect.mapError((cause) => stateError("write", cause))), + (directory) => + Effect.gen(function* () { + const temporary = path.join(directory, path.basename(target)); + yield* fs + .writeFileString(temporary, serialized, { mode: 0o600 }) + .pipe(Effect.mapError((cause) => stateError("write", cause))); + yield* publish(temporary, target); + }), + (directory) => + fs + .remove(directory, { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => stateError("cleanup", cause))), + ); + }); const save = Effect.fn("State.save")(function* (state: SavedStack) { yield* checkId(state.id); - const target = statePath(state.id); const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(SavedStack))(state).pipe( Effect.mapError((cause) => stateError("encode", cause)), ); - yield* fs - .makeDirectory(stackRoot(state.id), { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((cause) => stateError("write", cause))); - yield* Effect.acquireUseRelease( - fs - .makeTempDirectory({ directory: root, prefix: ".state-write-" }) - .pipe(Effect.mapError((cause) => stateError("write", cause))), - (directory) => - Effect.gen(function* () { - const temporary = path.join(directory, "state.json"); - yield* fs - .writeFileString(temporary, serialized, { mode: 0o600 }) - .pipe(Effect.mapError((cause) => stateError("write", cause))); - yield* publish(temporary, target); - }), - (directory) => - fs - .remove(directory, { recursive: true, force: true }) - .pipe(Effect.mapError((cause) => stateError("cleanup", cause))), - ); + yield* writeAtomically(state.id, statePath(state.id), serialized); }); const remove = Effect.fn("State.remove")(function* (id: string) { yield* checkId(id); - yield* fs - .remove(statePath(id), { force: true }) - .pipe(Effect.mapError((cause) => stateError("remove", cause))); + for (const file of [statePath(id), ownerPath(id), ownerLog(id)]) + yield* fs + .remove(file, { force: true }) + .pipe(Effect.mapError((cause) => stateError("remove", cause))); yield* removeEmptyDirectory(path.join(stackRoot(id), "data")); yield* removeEmptyDirectory(stackRoot(id)); }); + const openLock = (file: string) => + Effect.try({ + try: () => new DatabaseSync(file), + catch: (cause) => stateError("lock", cause), + }); + /** Opens an existing lock file without creating a stray one. */ + const openExistingLock = (file: string) => + Effect.try({ + try: () => new DatabaseSync(new URL(`${pathToFileURL(file).href}?mode=rw`)), + catch: (cause) => stateError("lock", cause), + }); + const closeLock = (connection: DatabaseSync) => + Effect.try({ + try: () => connection.close(), + catch: (cause) => stateError("unlock", cause), + }); + const hasErrcode = (code: number) => (error: StateError) => + Predicate.hasProperty(error.cause, "errcode") && error.cause.errcode === code; + const isBusy = hasErrcode(5); + const isMissing = hasErrcode(14); + /** An open file that was unlinked: SQLite IOERR_VNODE on macOS, IOERR_FSTAT on Linux. */ + const isMoved = (error: StateError) => hasErrcode(6922)(error) || hasErrcode(1802)(error); + /** Takes the file's SQLite write lock on this connection, retrying contention on `schedule`. */ + const takeLock = ( + connection: DatabaseSync, + schedule: Schedule.Schedule, + onContended: Effect.Effect = Effect.void, + ) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => connection.exec("PRAGMA busy_timeout = 0"), + catch: (cause) => stateError("lock", cause), + }); + let contended = false; + return yield* Effect.try({ + try: () => connection.exec("BEGIN IMMEDIATE"), + catch: (cause) => stateError("lock", cause), + }).pipe( + Effect.tapError((error) => + isBusy(error) && !contended + ? Effect.sync(() => { + contended = true; + }).pipe(Effect.andThen(onContended)) + : Effect.void, + ), + Effect.retry({ schedule, while: isBusy }), + Effect.as(true), + Effect.catchIf(isBusy, () => Effect.succeed(false)), + ); + }); const withLock = Effect.fn("State.withLock")((effect: Effect.Effect) => Effect.acquireUseRelease( - Effect.try({ - try: () => new DatabaseSync(lock), - catch: (cause) => stateError("lock", cause), - }), + openLock(lock), (connection) => Effect.gen(function* () { - yield* Effect.try({ - try: () => connection.exec("PRAGMA busy_timeout = 0"), - catch: (cause) => stateError("lock", cause), - }); - yield* Effect.try({ - try: () => connection.exec("BEGIN IMMEDIATE"), - catch: (cause) => stateError("lock", cause), - }).pipe( - Effect.retry({ - schedule: Schedule.spaced("50 millis").pipe( - Schedule.upTo({ duration: "5 seconds" }), - ), - while: (error) => - Predicate.hasProperty(error.cause, "errcode") && error.cause.errcode === 5, - }), - Effect.mapError((error) => - Predicate.hasProperty(error.cause, "errcode") && error.cause.errcode === 5 - ? new StateError({ - operation: "lock", - message: "Stack registry is locked by another operation; retry shortly", - cause: error.cause, - }) - : error, - ), + const held = yield* takeLock( + connection, + Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "5 seconds" })), ); + if (!held) + return yield* new StateError({ + operation: "lock", + message: "Stack registry is locked by another operation; retry shortly", + }); return yield* effect; }), - (connection) => - Effect.try({ - try: () => connection.close(), - catch: (cause) => stateError("unlock", cause), - }), + closeLock, ), ); - return { read, list: list(), claims: claims(), save, remove, withLock }; + /** Deletes the lease file of an unregistered stack; `false` means it is still in place. */ + const removeLeaseFile = (id: string) => + fs.exists(statePath(id)).pipe( + Effect.flatMap((registered) => + registered ? Effect.void : fs.remove(leasePath(id), { force: true }), + ), + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + const fileIdentity = (file: string) => + fs.stat(file).pipe( + Effect.map((info) => `${info.dev}:${Option.getOrElse(info.ino, () => "")}`), + Effect.option, + ); + /** + * A waiter can open the lease file just before its holder unlinks it, and then lock the + * unlinked file; comparing the path's file before opening and after locking rejects that. + * Unlinking under the lock keeps new openers off a doomed file. Windows refuses to unlink an + * open file, so only there does a second attempt follow the close. + */ + const lease = Effect.fn("State.lease")(function* (id: string) { + yield* checkId(id); + const target = leasePath(id); + const scope = yield* Scope.Scope; + let held = false; + let unlinked = false; + yield* Effect.addFinalizer(() => + held + ? (unlinked ? Effect.void : removeLeaseFile(id)).pipe( + Effect.andThen(removeEmptyDirectory(stackRoot(id))), + Effect.ignore, + ) + : Effect.void, + ); + const onContended = options.onLeaseContended?.(id) ?? Effect.void; + for (let attempt = 0; attempt < 8; attempt++) { + // A releasing holder may remove the empty stack directory at any moment. + yield* fs + .makeDirectory(stackRoot(id), { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => stateError("lease", cause))); + const before = yield* fileIdentity(target); + const attemptScope = yield* Scope.fork(scope, "sequential"); + const connection = yield* Effect.acquireRelease(openLock(target), (connection) => + closeLock(connection).pipe( + Effect.catch((error) => + Effect.logWarning(`Unable to release stack lease ${id}`, error), + ), + ), + ).pipe( + Scope.provide(attemptScope), + Effect.map(Option.some), + Effect.catchIf(isMissing, () => Effect.succeed(Option.none())), + ); + if (Option.isNone(connection)) { + yield* Scope.close(attemptScope, Exit.void); + continue; + } + const acquired = yield* takeLock( + connection.value, + Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "500 millis" })), + onContended, + ).pipe( + Effect.map((held) => (held ? "held" : "busy")), + Effect.catchIf(isMoved, () => Effect.succeed("moved" as const)), + ); + if (acquired === "busy") { + yield* Scope.close(attemptScope, Exit.void); + return false; + } + const after = acquired === "moved" ? Option.none() : yield* fileIdentity(target); + if (Option.isSome(before) && Option.isSome(after) && before.value === after.value) { + held = true; + yield* Scope.addFinalizer( + attemptScope, + removeLeaseFile(id).pipe( + Effect.map((removed) => { + unlinked = removed; + }), + ), + ); + return true; + } + yield* Scope.close(attemptScope, Exit.void); + } + return yield* stateError("lease", `The lease file of stack ${id} keeps changing`); + }); + const leased = Effect.fn("State.leased")(function* (id: string) { + yield* checkId(id); + return yield* Effect.acquireUseRelease( + openExistingLock(leasePath(id)), + (connection) => + takeLock(connection, Schedule.recurs(0)).pipe(Effect.map((acquired) => !acquired)), + closeLock, + ).pipe( + // A lease file that is missing, or that its holder unlinked meanwhile, is not held. + Effect.catchIf( + (error) => isMissing(error) || isMoved(error), + () => Effect.succeed(false), + ), + ); + }); + const decodeHolder = Schema.decodeEffect(Schema.fromJsonString(LeaseHolder)); + const readHolder = Effect.fn("State.readHolder")(function* (id: string) { + yield* checkId(id); + const target = ownerPath(id); + // The holder may retract its record at any moment, so a missing record is not an error. + const text = yield* fs.readFileString(target).pipe( + Effect.map(Option.some), + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(Option.none()), + ), + retryTransientRead, + ); + if (Option.isNone(text)) return undefined; + return yield* decodeHolder(text.value).pipe( + Effect.mapError((cause) => stateError("decode", `Unable to decode ${target}: ${cause}`)), + ); + }); + const publishHolder = Effect.fn("State.publishHolder")(function* ( + id: string, + record: LeaseHolder, + ) { + yield* checkId(id); + const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(LeaseHolder))( + record, + ).pipe(Effect.mapError((cause) => stateError("encode", cause))); + yield* writeAtomically(id, ownerPath(id), serialized); + }); + const retractHolder = Effect.fn("State.retractHolder")(function* (id: string) { + yield* checkId(id); + yield* fs + .remove(ownerPath(id), { force: true }) + .pipe(Effect.mapError((cause) => stateError("remove", cause))); + }); + return { + read, + list: list(), + claims: claims(), + save, + remove, + withLock, + lease, + leased, + readHolder, + publishHolder, + retractHolder, + ownerLog, + }; }); export const layer = (options: Options) => diff --git a/packages/stack/src/State.windows.integration.test.ts b/packages/stack/src/State.windows.integration.test.ts index 9e723affbe..c9080b2de9 100644 --- a/packages/stack/src/State.windows.integration.test.ts +++ b/packages/stack/src/State.windows.integration.test.ts @@ -20,6 +20,7 @@ const saved: State.SavedStack = { identity: { projectRoot: "C:\\project", branchContext: "test", stackName: "windows" }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; diff --git a/packages/stack/src/Sweep.integration.test.ts b/packages/stack/src/Sweep.integration.test.ts new file mode 100644 index 0000000000..70c1754af0 --- /dev/null +++ b/packages/stack/src/Sweep.integration.test.ts @@ -0,0 +1,210 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { + Clock, + Context, + Crypto, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { launchHost } from "./HostProcess.ts"; +import { makeContainerRuntime } from "./runtime/Container.ts"; +import * as State from "./State.ts"; +import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; +import { shutdownOwner, watchLeaseRelease } from "../tests/owner.ts"; + +class SweepTestError extends Data.TaggedError("SweepTestError")<{ readonly message: string }> {} + +const helperImage = + "public.ecr.aws/docker/library/debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251"; + +const docker = Effect.fn("SweepTest.docker")((args: ReadonlyArray) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(child.stdout)), + Stream.mkString(Stream.decodeText(child.stderr)), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (Number(code) !== 0) + return yield* Effect.die(`docker ${args.join(" ")} failed: ${stderr}`); + return stdout.trim(); + }), + ), +); + +const labels = (stackId: string, dataRoot: string) => [ + `label=com.supabase.stack=${stackId}`, + `label=com.supabase.stack-root=${dataRoot}`, +]; + +const containers = (stackId: string, dataRoot: string) => + docker([ + "ps", + "--all", + "--quiet", + "--no-trunc", + ...labels(stackId, dataRoot).flatMap((label) => ["--filter", label]), + ]).pipe(Effect.map((value) => value.split("\n").filter((id) => id.length > 0))); + +const createOwnedContainer = (stackId: string, dataRoot: string) => + Effect.acquireRelease( + docker([ + "create", + ...labels(stackId, dataRoot).flatMap((label) => ["--label", label.slice("label=".length)]), + helperImage, + ]), + (id) => docker(["rm", "--force", id]).pipe(Effect.ignore), + ); + +/** Completes when Docker reports the container destroyed, including events before subscription. */ +const awaitDestroyed = (id: string, since: number) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const events = yield* spawner.spawn( + ChildProcess.make( + "docker", + [ + "events", + "--since", + String(since), + "--filter", + `container=${id}`, + "--filter", + "event=destroy", + "--format", + "{{.Actor.ID}}", + ], + { stdin: "ignore", stdout: "pipe", stderr: "ignore" }, + ), + ); + const destroyed = yield* events.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.filter((line) => line.trim() === id), + Stream.runHead, + ); + if (Option.isNone(destroyed)) + return yield* new SweepTestError({ message: "Docker event stream ended" }); + }), + ); + +const awaitRemoval = (directory: string, entry: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(directory, entry); + yield* fs.watch(directory).pipe( + Stream.filter((event) => event.path === entry || event.path === target), + Stream.mapEffect(() => fs.exists(target)), + Stream.takeUntil((exists) => !exists), + Stream.runDrain, + ); + }); + +const saved = ( + id: string, + projectRoot: string, + runtime: State.SavedStack["runtime"], + lifetime: State.StackLifetime, +): State.SavedStack => ({ + id, + runtime, + lifetime, + identity: { projectRoot, branchContext: "sweep-test", stackName: id }, + instances: [], + composition: { members: [], dependencies: [] }, + ports: [], +}); + +it.live.skipIf(process.platform === "win32")( + "removes a dead owner's containers and session stacks at the next owner start in its root", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const suffix = (yield* crypto.randomUUIDv4).replaceAll("-", ""); + const deadId = `dead-${suffix}`; + const dataA = yield* makeDockerDatabaseRoot("stack-sweep-a-", deadId).pipe( + Effect.flatMap(fs.realPath), + ); + const dataB = yield* makeDockerDatabaseRoot("stack-sweep-b-", deadId).pipe( + Effect.flatMap(fs.realPath), + ); + const rootA = path.dirname(path.dirname(dataA)); + const cacheRoot = `${path.dirname(rootA)}/cache`; + const helper = yield* makeContainerRuntime({ engine: "docker", root: dataA }); + yield* helper.prepare(helperImage); + const state = Context.get(yield* Layer.build(State.layer({ root: rootA })), State.Service); + + yield* state.save(saved(deadId, `${rootA}/dead`, "docker", "detached")); + const dead = (yield* launchHost(state, { stateRoot: rootA, cacheRoot, stackId: deadId })) + .endpoint; + const orphan = yield* createOwnedContainer(deadId, dataA); + const otherRoot = yield* createOwnedContainer(deadId, dataB); + const deadReleased = yield* watchLeaseRelease(rootA, deadId); + yield* Effect.sync(() => process.kill(dead.pid, "SIGKILL")); + yield* deadReleased; + expect(yield* containers(deadId, dataA)).toEqual([orphan]); + + const sessionId = `session-${suffix}`; + yield* state.save(saved(sessionId, `${rootA}/session`, "native", "session")); + const nextId = `next-${suffix}`; + yield* state.save(saved(nextId, `${rootA}/next`, "native", "detached")); + + const since = Math.floor((yield* Clock.currentTimeMillis) / 1000) - 1; + const swept = yield* Effect.all( + [ + awaitDestroyed(orphan, since), + awaitRemoval(rootA, sessionId), + awaitRemoval(path.join(rootA, deadId), "owner.json"), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild({ startImmediately: true })); + const next = yield* Effect.acquireRelease( + launchHost(state, { stateRoot: rootA, cacheRoot, stackId: nextId }), + (access) => shutdownOwner(access, true).pipe(Effect.ignore), + ); + yield* Fiber.join(swept).pipe( + Effect.timeoutOrElse({ + duration: "1 minute", + orElse: () => + Effect.fail(new SweepTestError({ message: "The next owner did not sweep orphans" })), + }), + ); + + // The sweeper retracts its marker, then releases the lease. + yield* Effect.scoped( + Effect.gen(function* () { + yield* yield* watchLeaseRelease(rootA, deadId); + }), + ); + expect(next.endpoint.pid).not.toBe(dead.pid); + expect(yield* containers(deadId, dataA)).toEqual([]); + expect(yield* containers(deadId, dataB), "another root is never swept").toEqual([ + otherRoot, + ]); + expect(yield* state.read(deadId), "detached stacks keep their state").toBeDefined(); + expect(yield* state.read(sessionId)).toBeUndefined(); + expect(yield* state.leased(deadId), "the sweeper released the dead stack").toBe(false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 180_000 }, +); diff --git a/packages/stack/src/Sweep.ts b/packages/stack/src/Sweep.ts new file mode 100644 index 0000000000..6817993f21 --- /dev/null +++ b/packages/stack/src/Sweep.ts @@ -0,0 +1,79 @@ +import { Context, DateTime, Effect, FileSystem, Layer, Path } from "effect"; +import { sweepTimeout } from "./HostProcess.ts"; +import * as Owner from "./Owner.ts"; +import * as State from "./State.ts"; + +/** Runs a dead session stack's own destroy path in this process while holding its lease. */ +const destroyStack = Effect.fn("Sweep.destroyStack")(function* ( + state: State.Interface, + saved: State.SavedStack, + dataRoot: string, + cacheRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dataRoot, { recursive: true }); + const context = yield* Layer.build( + Owner.layer({ saved, root: dataRoot, cacheRoot }).pipe( + Layer.provide(Layer.succeed(State.Service, state)), + ), + ); + yield* Context.get(context, Owner.Service).namespace.destroy; +}); + +/** + * Cleans up a stack whose lease is free, holding that lease meanwhile and marking the hold so + * clients wait instead of mistaking it for a starting owner. Removes the stack's labelled + * containers, and destroys the stack when its lifetime is `session`. Returns `false` when + * another process holds the lease. + */ +export const reclaimStack = Effect.fn("Sweep.reclaimStack")(function* (options: { + readonly state: State.Interface; + readonly stateRoot: string; + readonly cacheRoot: string; + readonly id: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { state, id } = options; + return yield* Effect.scoped( + Effect.gen(function* () { + if (!(yield* state.lease(id))) return false; + yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); + yield* state.publishHolder(id, { + role: "sweeper", + pid: process.pid, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + const saved = yield* state.read(id); + if (saved === undefined) return true; + const dataRoot = path.join(yield* fs.realPath(options.stateRoot), id, "data"); + if (saved.lifetime === "session") + yield* destroyStack(state, saved, dataRoot, options.cacheRoot); + else yield* Owner.sweepContainers(saved, dataRoot); + return true; + }), + ).pipe(Effect.timeout(sweepTimeout)); +}); + +/** + * Keeps stack-labelled containers and session stacks alive only while their lease is held: every + * other stack of this state root whose lease is free is reclaimed. + */ +export const sweepOrphans = Effect.fn("Sweep.orphans")( + function* (options: { + readonly state: State.Interface; + readonly stateRoot: string; + readonly cacheRoot: string; + readonly ownerId: string; + }) { + for (const { id } of yield* options.state.list) { + if (id === options.ownerId) continue; + yield* reclaimStack({ ...options, id }).pipe( + Effect.catchCause((cause) => + Effect.logWarning(`Orphan sweep of stack ${id} failed`, cause), + ), + ); + } + }, + Effect.catchCause((cause) => Effect.logWarning("Orphan sweep failed", cause)), +); diff --git a/packages/stack/src/composition/Supabase.native.integration.test.ts b/packages/stack/src/composition/Supabase.native.integration.test.ts index f06dc92d39..c208f185e1 100644 --- a/packages/stack/src/composition/Supabase.native.integration.test.ts +++ b/packages/stack/src/composition/Supabase.native.integration.test.ts @@ -58,6 +58,7 @@ const initial = (id: string): SavedStack => ({ identity: { projectRoot: "/tmp/project", branchContext: "catalog-native", stackName: id }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 7f4eba75c5..51ba2f9f61 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -1,11 +1,31 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, expectTypeOf, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Schema } from "effect"; +import { + Cause, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Redacted, + Schema, + Scope, + Stream, +} from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the test binds a dead owner's exact port. +import * as Net from "node:net"; import { tmpdir } from "node:os"; import { create, discover, open, type DatabaseInstance, type ServiceInstance } from "./effect.ts"; import { initialization, postgres } from "./Commands.ts"; +import { fileURLToPath } from "node:url"; +import { launchHost } from "./HostProcess.ts"; import * as PromiseApi from "./index.ts"; +import * as State from "./State.ts"; +import { assertOwnerExited, watchLeaseRelease } from "../tests/owner.ts"; +import { foreignRelease } from "../tests/release-owner-fixture.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; +import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); const databaseOwnerMarker = Schema.fromJsonString( @@ -371,3 +391,417 @@ it.live("resets native database data through the public RPC", () => resetDataSto it.live("resets Docker database data through the public RPC", () => resetDataStory("docker"), { timeout: 15 * 60_000, }); + +it.live("stops an instance without starting an owner when none is live", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-idle-stop-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + yield* Effect.ensuring( + Effect.gen(function* () { + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* stack.stop; + expect((yield* discover(options))[0]?.host).toBeUndefined(); + + yield* mail.stop; + expect(yield* stack.composition.stop).toEqual([]); + yield* stack.stop; + expect((yield* discover(options))[0]?.host).toBeUndefined(); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("rejects an owner of another release while stop and destroy still reach it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-release-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const startForeignOwner = launchHost(state, { + ...options, + stackId: stack.id, + entrypoint: fileURLToPath(new URL("../tests/release-owner-fixture.ts", import.meta.url)), + }); + + const stale = yield* startForeignOwner; + expect(stale.endpoint.release).toBe(foreignRelease); + const rejected = yield* Effect.flip(stack.composition.start); + expect(rejected.reason).toBe("release-mismatch"); + expect(rejected.message).toContain(`served by release ${foreignRelease}`); + expect(rejected.message).toContain("stop or destroy the stack"); + yield* stack.stop; + yield* assertOwnerExited(stale.endpoint.pid); + + const doomed = yield* startForeignOwner; + yield* stack.destroy; + yield* assertOwnerExited(doomed.endpoint.pid); + expect(yield* state.read(stack.id)).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("treats a sweeper's hold as no owner and starts one once the sweep ends", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-sweeping-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + yield* Effect.ensuring( + Effect.gen(function* () { + const sweep = yield* Scope.make(); + expect(yield* state.lease(stack.id).pipe(Scope.provide(sweep))).toBe(true); + yield* state.publishHolder(stack.id, { + role: "sweeper", + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + }); + yield* stack.stop; + expect(yield* stack.composition.stop).toEqual([]); + expect((yield* discover(options))[0]?.host).toBeUndefined(); + + const starting = yield* stack.composition.start.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* state.retractHolder(stack.id); + yield* Scope.close(sweep, Exit.void); + expect(yield* Fiber.join(starting)).toEqual([]); + expect((yield* discover(options))[0]?.host).toBeDefined(); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("interrupts a call waiting for an owner while a sweeper holds the stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-interrupt-launch-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + yield* Effect.ensuring( + Effect.gen(function* () { + const sweep = yield* Scope.make(); + expect(yield* state.lease(stack.id).pipe(Scope.provide(sweep))).toBe(true); + yield* state.publishHolder(stack.id, { + role: "sweeper", + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + }); + + const waited = yield* stack.composition.start.pipe(Effect.timeoutOption("200 millis")); + + expect(Option.isNone(waited), "the wait for the sweeper ends at the timeout").toBe(true); + yield* state.retractHolder(stack.id); + yield* Scope.close(sweep, Exit.void); + expect(yield* stack.composition.start, "the handle still launches afterwards").toEqual([]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("reports a stopped owner as unavailable to attach-only calls", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-unavailable-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* stack.stop; + + const unavailable = yield* Effect.flip(mail.status); + + expect(unavailable.reason).toBe("owner-unavailable"); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +/** Binds a loopback port and resets every connection it accepts, counting them. */ +const countConnectionsOn = (port: number) => + Effect.acquireRelease( + Effect.callback<{ readonly server: Net.Server; readonly accepted: { count: number } }>( + (resume) => { + const accepted = { count: 0 }; + const server = Net.createServer((socket) => { + accepted.count++; + socket.destroy(); + }); + server.once("error", (cause) => resume(Effect.die(cause))); + server.listen(port, "127.0.0.1", () => resume(Effect.succeed({ server, accepted }))); + }, + ), + ({ server }) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe( + Effect.map( + ({ accepted }) => + () => + accepted.count, + ), + ); + +it.live("sends no call to a process that took a dead owner's port", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-dead-owner-port-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* mail.status; + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const holder = yield* state.readHolder(stack.id); + if (holder?.role !== "owner") return yield* Effect.die("Expected a live owner record"); + const released = yield* watchLeaseRelease(options.stateRoot, stack.id); + yield* Effect.sync(() => process.kill(holder.pid, "SIGKILL")); + yield* released; + const accepted = yield* countConnectionsOn(holder.port); + + const unavailable = yield* Effect.flip(mail.status); + + expect(accepted(), "connections reaching the dead owner's port").toBe(0); + expect(unavailable.reason).toBe("owner-unavailable"); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("sends no call to a process that took the port of a replaced owner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-replaced-owner-port-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* mail.status; + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const replaced = yield* state.readHolder(stack.id); + if (replaced?.role !== "owner") return yield* Effect.die("Expected a live owner record"); + const released = yield* watchLeaseRelease(options.stateRoot, stack.id); + yield* Effect.sync(() => process.kill(replaced.pid, "SIGKILL")); + yield* released; + const accepted = yield* countConnectionsOn(replaced.port); + const other = yield* open({ ...options, id: stack.id }); + expect(yield* other.composition.start).toEqual([]); + + const status = yield* Effect.exit(mail.status); + + expect(accepted(), "connections reaching the replaced owner's port").toBe(0); + expect(Exit.isSuccess(status) && status.value.id).toBe(mail.id); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live( + "releases each call's owner connection in the call's scope, not the handle's", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-call-scope-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, + runtime: "native", + } satisfies Parameters[0]; + const handleScope = yield* Scope.make(); + const stack = yield* create(options).pipe(Scope.provide(handleScope)); + const handleFinalizers = () => + handleScope.state._tag === "Open" + ? (handleScope.state.finalizers?.size ?? 0) + + (handleScope.state.finalizerKey === undefined ? 0 : 1) + : 0; + const useHandle = (mail: ServiceInstance<"mail">) => + Effect.gen(function* () { + yield* mail.status; + yield* mail.followStatus.pipe(Stream.take(1), Stream.runDrain); + const version = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--version"], + stdout: () => Effect.void, + stderr: () => Effect.void, + }); + expect(version.exitCode).toBe(0); + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* useHandle(mail); + const settled = handleFinalizers(); + + yield* useHandle(mail); + yield* useHandle(mail); + expect(handleFinalizers(), "repeated calls add nothing to the handle").toBe(settled); + + const other = yield* open({ ...options, id: stack.id }); + yield* other.stop; + expect(yield* other.composition.start).toEqual([]); + yield* useHandle(mail); + expect(handleFinalizers(), "the retired connection closed after its last use").toBe( + settled, + ); + }), + destroyTestStack(stack).pipe(Effect.andThen(Scope.close(handleScope, Exit.void))), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), + { timeout: 120_000 }, +); + +it.live("confirms owner exit after shutdown even while a stray handle keeps its loop alive", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-exit-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const owner = yield* launchHost(state, { + ...options, + stackId: stack.id, + entrypoint: fileURLToPath(new URL("../tests/lingering-owner-fixture.ts", import.meta.url)), + }); + + yield* stack.stop; + + yield* assertOwnerExited(owner.endpoint.pid); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("replaces a dead session stack that holds the requested identity", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-replace-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + lifetime: "session", + } satisfies Parameters[0]; + const identity = yield* resolveStackIdentity(options); + const id = yield* deriveStackId(identity); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + yield* state.save({ + id, + identity, + lifetime: "session", + runtime: "native", + instances: [{ id: "abandoned", creation: { service: "mail", config: {} } }], + composition: { members: [], dependencies: [] }, + ports: [], + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* create(options); + expect(stack.id).toBe(id); + expect(yield* stack.services.list).toEqual([]); + }), + ); + expect(yield* state.read(id)).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live( + "re-resolves a restarted owner for calls and commands on a long-lived handle", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-reconnect-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, + runtime: "native", + } satisfies Parameters[0]; + const stack = yield* create(options); + const version = (stack: Effect.Success>) => + stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--version"], + stdout: () => Effect.void, + stderr: () => Effect.void, + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const mail = yield* stack.services.create({ service: "mail", config: {} }); + expect((yield* version(stack)).exitCode).toBe(0); + const first = (yield* discover(options))[0]?.host; + + const other = yield* open({ ...options, id: stack.id }); + yield* other.stop; + expect(yield* other.composition.start).toEqual([]); + const second = (yield* discover(options))[0]?.host; + expect(second?.port).toBeDefined(); + expect(second?.pid).not.toBe(first?.pid); + + expect( + (yield* version(stack)).exitCode, + "a command-only call follows the new owner", + ).toBe(0); + yield* other.stop; + expect(yield* other.composition.start).toEqual([]); + expect((yield* mail.status).lifecycle, "a call follows the new owner").toBe("stopped"); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), + { timeout: 120_000 }, +); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 38dedaf7c3..bad440e6d3 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -1,38 +1,45 @@ import { Cause, + Context, Crypto, + DateTime, Deferred, Effect, Exit, FileSystem, Fiber, - Layer, Match, Option, Path, + Predicate, Ref, Scope, Schema, + Semaphore, Stream, } from "effect"; import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { connectHost, - controlPortHeld, + hasReason, HostProcessError, launchHost, + observeHost, + ownerClient, + shutdownHost, waitForOwnerExit, + type HostAccess, } from "./HostProcess.ts"; +import type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; import { removeStackContainersCommand } from "./runtime/Container.ts"; import { volumeDataCleanupCommands } from "./storage/DockerDatabaseStorage.ts"; -import type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; +import { failureMessage } from "./internal/failure-message.ts"; import * as State from "./State.ts"; import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; -import { StackError, StackRpc, type Definition, type Observation } from "./Rpc.ts"; +import { StackError, type Definition, type Observation } from "./Rpc.ts"; +import { reclaimStack } from "./Sweep.ts"; import { ServiceCreationInput as ServiceCreationInputSchema, type ServiceCreation, @@ -88,6 +95,15 @@ export interface CreateOptions extends StackLocations { readonly projectRoot: string; readonly name?: string; readonly runtime: "native" | "docker" | "podman"; + /** + * A `session` stack starts its owner at creation and is destroyed when the creating handle's + * scope closes or its process exits; a `detached` stack (the default) outlives its creator. + */ + readonly lifetime?: State.StackLifetime; + /** + * Starts the owner at creation and lets it register the stack under its lease, so a failed or + * interrupted launch leaves no registration behind. Session stacks always do this. + */ readonly startOwner?: boolean; } /** Opens a previously registered stack. */ @@ -96,6 +112,11 @@ export interface OpenOptions extends StackLocations { readonly startOwner?: boolean; } +/** No owner serves the stack: nothing holds its lease, or a sweeper is cleaning it up. */ +const ownerAbsent = hasReason("not-running", "sweeping"); +/** The stack is no longer registered: it was destroyed or swept. */ +const stackGone = hasReason("unregistered"); + const failure = (operation: string, cause: unknown): StackError => Schema.is(StackError)(cause) ? cause @@ -103,9 +124,12 @@ const failure = (operation: string, cause: unknown): StackError => operation, message: Schema.is(RpcClientError)(cause) ? `Owner response unavailable; the request outcome is uncertain: ${cause.message}` - : cause instanceof Error - ? cause.message - : String(cause), + : failureMessage(cause), + ...(ownerAbsent(cause) || stackGone(cause) + ? { reason: "owner-unavailable" as const } + : hasReason("release-mismatch")(cause) + ? { reason: "release-mismatch" as const } + : {}), }); type Kind = ServiceCreation["service"]; @@ -191,7 +215,7 @@ export interface CommandRunner { options?: InitializationCommandOptions, ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; } -/** A client handle; its lifetime does not own service processes. */ +/** A client handle; only the creating handle of a session stack owns its services. */ export interface Stack { readonly id: string; readonly services: { @@ -222,140 +246,329 @@ export interface Stack { }; } -type Client = Effect.Success>; -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); +type Client = Effect.Success>; -const makeHandle = Effect.fn("Stack.makeHandle")(function* ( +const causeCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null) return undefined; + if ("code" in cause && typeof cause.code === "string") return cause.code; + if ("cause" in cause) return causeCode(cause.cause); + if ("reason" in cause) return causeCode(cause.reason); + return undefined; +}; +/** + * A refused connection, or a listener that rejects the owner secret, proves the request never + * reached this stack's owner: the owner is gone or another process holds its port. Resending + * after re-resolving the owner is safe. Other transport failures leave the connection in place. + */ +const ownerGone = (cause: unknown) => + Schema.is(RpcClientError)(cause) && + (["ECONNREFUSED", "ConnectionRefused"].includes(causeCode(cause) ?? "") || + (cause.reason._tag === "HttpError" && + Predicate.hasProperty(cause.reason.cause, "response") && + Predicate.hasProperty(cause.reason.cause.response, "status") && + cause.reason.cause.response.status === 401)); + +interface Connection { + readonly access: HostAccess; + readonly rpc: Client; + readonly scope: Scope.Closeable; + /** Calls and streams currently using this client. */ + active: number; + /** Replaced by a newer connection; closes once its last user finishes. */ + retired: boolean; +} +/** Finds a directory below `directory` that the current user cannot empty and delete. */ +const firstUnremovableDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, +): Effect.Effect => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(directory).pipe(Effect.option); + const writable = yield* fs.access(directory, { writable: true }).pipe(Effect.isSuccess); + if (Option.isNone(entries) || !writable) return directory; + for (const entry of entries.value) { + const child = path.join(directory, entry); + const info = yield* fs.stat(child).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "Directory") continue; + if (yield* fs.readLink(child).pipe(Effect.isSuccess)) continue; + const blocked = yield* firstUnremovableDirectory(fs, path, child); + if (blocked !== undefined) return blocked; + } + return undefined; + }); + +/** + * Destroys a stack whose owner cannot start because its container engine is unreachable: under + * the stack's lease it removes the registration and host data, and returns the commands that + * remove the containers and engine-volume data left behind. + */ +const destroyWithoutEngine = Effect.fn("Stack.destroyWithoutEngine")(function* ( state: State.Interface, saved: SavedStack, locations: StackLocations, + engine: "docker" | "podman", ) { - const http = yield* HttpClient.HttpClient; - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const endpointFor = (live: boolean) => - (live - ? launchHost(state, { ...locations, stackId: saved.id }) - : connectHost(state, saved.id) - ).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + const id = saved.id; + const dataRoot = path.join(locations.stateRoot, id, "data"); + return yield* Effect.scoped( + Effect.gen(function* () { + if (!(yield* state.lease(id))) + return yield* failure( + "destroy", + "An owner for this stack started during destroy; run destroy again", + ); + yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); + yield* state.publishHolder(id, { + role: "sweeper", + pid: process.pid, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + const current = yield* state.read(id); + // Container-written host data, such as database files below Docker 26, can belong to the + // container user; only the engine can delete it, so refuse before deleting anything. + const blocked = + current !== undefined && (yield* fs.exists(dataRoot)) + ? yield* firstUnremovableDirectory(fs, path, dataRoot) + : undefined; + if (blocked !== undefined) { + const engineName = engine === "docker" ? "Docker" : "Podman"; + return yield* failure( + "destroy", + `Stack data at ${blocked} can only be removed by ${engineName}; start ${engineName} and run destroy again`, + ); + } + // Containers are labelled with the resolved data root the owner ran with. + const root = yield* fs.realPath(dataRoot).pipe(Effect.orElseSucceed(() => dataRoot)); + const cleanupCommands = [ + removeStackContainersCommand({ engine, stackId: id, root }), + ...(yield* volumeDataCleanupCommands({ engine, root, fs, path })), + ]; + if (current !== undefined) { + yield* fs.remove(dataRoot, { recursive: true, force: true }); + yield* state.withLock(state.remove(id)); + } + return { runtimeCleanup: "skipped", engine, cleanupCommands } as const; + }), + ).pipe(Effect.mapError((cause) => failure("destroy", cause))); +}); + +/** `launch` starts an owner when none is live; `attach` requires a live one. */ +type Reach = "launch" | "attach"; + +const makeHandle = Effect.fn("Stack.makeHandle")(function* ( + state: State.Interface, + saved: SavedStack, + locations: StackLocations, + seed: { readonly access?: HostAccess; readonly creator?: boolean } = {}, +) { + // Calls and streams release what they borrow in their own scope, not in the handle's. + const services = Context.omit(Scope.Scope)( + yield* Effect.context< + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope + >(), + ); + const crypto = yield* Crypto.Crypto; + const handleScope = yield* Scope.Scope; + const session = saved.lifetime === "session"; + const launchOptions = { ...locations, stackId: saved.id, lifeline: session }; + const cached = yield* Ref.make(undefined); + const gate = yield* Semaphore.make(1); + const connectTo = (access: HostAccess) => + Effect.gen(function* () { + const scope = yield* Scope.fork(handleScope, "sequential"); + const rpc = yield* ownerClient(access).pipe(Scope.provide(scope)); + const connection: Connection = { access, rpc, scope, active: 0, retired: false }; + return connection; + }); + const resolve = (reach: Reach) => + // A session stack's owner is spawned only by its creator, which holds the lifeline. + reach === "launch" && (!session || seed.creator === true) + ? launchHost(state, launchOptions) + : connectHost(state, saved.id).pipe( + Effect.mapError((cause) => + session && ownerAbsent(cause) && reach === "launch" + ? new HostProcessError({ + operation: "connect", + message: `Session stack ${saved.id} has no live owner; it ends when its creator exits`, + reason: "not-running", + }) + : cause, + ), + ); + const closeIfIdle = (connection: Connection) => + Effect.suspend(() => + connection.retired && connection.active === 0 + ? Scope.close(connection.scope, Exit.void) + : Effect.void, + ); + /** Stops lending a connection; calls and streams already using it run to completion. */ + const retire = (current: Connection) => + Ref.set(cached, undefined).pipe( + Effect.andThen( + Effect.suspend(() => { + current.retired = true; + return closeIfIdle(current); + }), + ), ); - const client = (live: boolean) => + /** Whether the lease is held by the owner this connection reached, not a successor or sweeper. */ + const stillOwned = ({ access }: Connection) => + Effect.gen(function* () { + if (!(yield* state.leased(saved.id))) return false; + const holder = yield* state.readHolder(saved.id); + return ( + holder?.role === "owner" && + holder.pid === access.endpoint.pid && + holder.port === access.endpoint.port && + holder.secret === access.secret + ); + }); + const connection = (reach: Reach) => + Effect.gen(function* () { + const existing = yield* Ref.get(cached); + if (existing !== undefined) { + // Another process can bind a departed owner's port, so reuse needs that owner's lease. + if (yield* stillOwned(existing)) return existing; + yield* retire(existing); + } + // A spawned session owner's lifeline belongs to the handle, not to this call. + const access = yield* resolve(reach).pipe(Effect.provideService(Scope.Scope, handleScope)); + return yield* connectTo(access).pipe( + Effect.tap((connected) => Ref.set(cached, connected)), + Effect.uninterruptible, + ); + }); + /** + * Uses the cached connection for the enclosing scope, resolving the owner when there is none. + * Waiting for the gate or the owner stays interruptible. + */ + const borrow = (reach: Reach) => Effect.gen(function* () { - const endpoint = yield* endpointFor(live); - return yield* clientFor(endpoint.port); - }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + const scope = yield* Scope.Scope; + return yield* gate.withPermits(1)( + connection(reach).pipe( + Effect.tap((current) => + Effect.sync(() => { + current.active++; + }).pipe( + Effect.andThen( + Scope.addFinalizer( + scope, + Effect.suspend(() => { + current.active--; + return closeIfIdle(current); + }), + ), + ), + Effect.uninterruptible, + ), + ), + ), + ); + }); + /** Drops the cached connection; calls and streams already using it run to completion. */ + const invalidate = (connection?: Connection) => + gate.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(cached); + if (current === undefined || (connection !== undefined && current !== connection)) return; + yield* retire(current); + }), + ); + const dropIfGone = (connection: Connection) => (cause: unknown) => + ownerGone(cause) ? invalidate(connection) : Effect.void; + if (seed.access !== undefined) yield* Ref.set(cached, yield* connectTo(seed.access)); + const invoke = (run: (rpc: Client) => Effect.Effect, reach: Reach) => + Effect.scoped( + Effect.gen(function* () { + const current = yield* borrow(reach); + return yield* run(current.rpc).pipe(Effect.tapError(dropIfGone(current))); + }), + ).pipe(Effect.retry({ times: 1, while: ownerGone }), Effect.provideContext(services)); const call = ( operation: string, run: (rpc: Client) => Effect.Effect, - live = true, + reach: Reach = "launch", + ) => invoke(run, reach).pipe(Effect.mapError((cause) => failure(operation, cause))); + /** Without a live owner, or with a destroyed stack, nothing runs, so the request is already satisfied. */ + const whileRunning = ( + operation: string, + run: (rpc: Client) => Effect.Effect, + idle: A, ) => - Effect.scoped(Effect.flatMap(client(live), run)).pipe( + invoke(run, "attach").pipe( + Effect.catchIf( + (cause) => ownerAbsent(cause) || stackGone(cause), + () => Effect.succeed(idle), + ), Effect.mapError((cause) => failure(operation, cause)), ); const stream = (operation: string, run: (rpc: Client) => Stream.Stream) => - Stream.unwrap(Effect.map(client(false), run)).pipe( - Stream.mapError((cause) => failure(operation, cause)), - ); - /** - * Removes the stack's registration and host data without its owner, for a destroy that finds - * no owner running and can't start one because its container engine is unreachable. Its - * engine resources remain, and the result carries the commands that remove them. - */ - const firstUnremovableDirectory = (directory: string): Effect.Effect => - Effect.gen(function* () { - const entries = yield* fs.readDirectory(directory).pipe(Effect.option); - const writable = yield* fs.access(directory, { writable: true }).pipe(Effect.isSuccess); - if (Option.isNone(entries) || !writable) return directory; - for (const entry of entries.value) { - const child = path.join(directory, entry); - const info = yield* fs.stat(child).pipe(Effect.option); - if (Option.isNone(info) || info.value.type !== "Directory") continue; - if (yield* fs.readLink(child).pipe(Effect.isSuccess)) continue; - const blocked = yield* firstUnremovableDirectory(child); - if (blocked !== undefined) return blocked; - } - return undefined; - }); - const offlineDestroy = Effect.fn("Stack.offlineDestroy")(function* (engine: "docker" | "podman") { - const dataRoot = path.join(locations.stateRoot, saved.id, "data"); - return yield* state - .withLock( - Effect.gen(function* () { - const current = yield* state.read(saved.id); - if (current !== undefined && (yield* controlPortHeld(current))) - return yield* failure( - "destroy", - "An owner for this stack started during destroy; run destroy again", - ); - // Container-written host data, such as database files below Docker 26, can belong to the - // container user; only the engine can delete it, so refuse before deleting anything. - const blocked = - current !== undefined && (yield* fs.exists(dataRoot)) - ? yield* firstUnremovableDirectory(dataRoot) - : undefined; - if (blocked !== undefined) { - const engineName = engine === "docker" ? "Docker" : "Podman"; - return yield* failure( - "destroy", - `Stack data at ${blocked} can only be removed by ${engineName}; start ${engineName} and run destroy again`, - ); - } - // Containers are labelled with the resolved data root the owner ran with. - const root = yield* fs.realPath(dataRoot).pipe(Effect.orElseSucceed(() => dataRoot)); - const cleanupCommands = [ - removeStackContainersCommand({ engine, stackId: saved.id, root }), - ...(yield* volumeDataCleanupCommands({ engine, root, fs, path })), - ]; - if (current !== undefined) { - yield* fs.remove(dataRoot, { recursive: true, force: true }); - yield* state.remove(saved.id); - } - return { runtimeCleanup: "skipped", engine, cleanupCommands } as const; - }), - ) - .pipe(Effect.mapError((cause) => failure("destroy", cause))); - }); - + Stream.unwrap( + borrow("attach").pipe( + Effect.map((current) => run(current.rpc).pipe(Stream.tapError(dropIfGone(current)))), + Effect.provideContext(services), + ), + ).pipe(Stream.mapError((cause) => failure(operation, cause))); const shutdown = Effect.fn("Stack.shutdown")(function* (destroy: boolean) { const operation = destroy ? "destroy" : "shutdown"; - const endpointExit = yield* Effect.scoped(endpointFor(destroy)).pipe(Effect.exit); - if (Exit.isFailure(endpointExit)) { - const endpointFailure = Option.getOrUndefined(Cause.findErrorOption(endpointExit.cause)); - if ( - destroy && - saved.runtime !== "native" && - endpointFailure instanceof HostProcessError && - endpointFailure.reason === "runtime-unavailable" - ) - return yield* offlineDestroy(saved.runtime); - return yield* Option.match(Cause.findErrorOption(endpointExit.cause), { - onNone: () => Effect.fail(failure(operation, Cause.pretty(endpointExit.cause))), - onSome: (cause) => Effect.fail(failure(operation, cause)), - }); - } - const endpoint = endpointExit.value; - const shutdownExit = yield* Effect.scoped( - clientFor(endpoint.port).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.flatMap((rpc) => rpc.shutdown({ destroy })), - Effect.exit, - ), + yield* invalidate(); + const engine = saved.runtime === "native" ? undefined : saved.runtime; + // Shutdown uses the release-stable endpoint, so it reaches owners of any release. + const { endpoint, refusal, engineUnavailable } = yield* Effect.scoped( + Effect.gen(function* () { + const idle = { endpoint: undefined, refusal: Exit.void, engineUnavailable: false }; + const live = yield* connectHost(state, saved.id, { anyRelease: true }).pipe( + Effect.map(Option.some), + Effect.catchIf(ownerAbsent, () => + destroy + ? launchHost(state, launchOptions).pipe(Effect.map(Option.some)) + : Effect.succeed(Option.none()), + ), + Effect.catchIf(stackGone, () => Effect.succeed(Option.none())), + // The owner's startup sweep reports an unreachable engine before any owner serves. + Effect.catchIf( + (cause) => engine !== undefined && hasReason("runtime-unavailable")(cause), + () => Effect.succeed("engine-unavailable" as const), + ), + ); + if (live === "engine-unavailable") return { ...idle, engineUnavailable: true }; + if (Option.isNone(live)) return idle; + const access = live.value; + const endpoint = access.endpoint; + const refusal = yield* shutdownHost(access, destroy).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (rejected) => + Effect.fail( + new StackError({ + operation: "shutdown", + message: rejected.message, + ...(rejected.outcomes === undefined ? {} : { outcomes: rejected.outcomes }), + }), + ), + }), + ), + Effect.exit, + ); + return { endpoint, refusal, engineUnavailable: false }; + }), + ).pipe( + Effect.provideContext(services), + Effect.mapError((cause) => failure(operation, cause)), ); - if (Exit.isFailure(shutdownExit)) { - const shutdownFailure = Option.match(Cause.findErrorOption(shutdownExit.cause), { - onNone: () => failure(operation, Cause.pretty(shutdownExit.cause)), + if (engineUnavailable && engine !== undefined) + return yield* destroyWithoutEngine(state, saved, locations, engine).pipe( + Effect.provideContext(services), + ); + if (endpoint === undefined) return { runtimeCleanup: "complete" } as const; + if (Exit.isFailure(refusal)) { + const shutdownFailure = Option.match(Cause.findErrorOption(refusal.cause), { + onNone: () => failure(operation, Cause.pretty(refusal.cause)), onSome: (cause) => failure(operation, cause), }); if (!destroy) return yield* shutdownFailure; @@ -388,8 +601,8 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( id, service, start: call("start", (rpc) => rpc.startService({ id })), - ready: call("ready", (rpc) => rpc.readyService({ id }), false), - stop: call("stop", (rpc) => rpc.stopService({ id })), + ready: call("ready", (rpc) => rpc.readyService({ id }), "attach"), + stop: whileRunning("stop", (rpc) => rpc.stopService({ id }), undefined), restart: (input) => input === undefined ? call("restart", (rpc) => rpc.restartService({ id })) @@ -403,7 +616,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( ), destroy: call("destroy", (rpc) => rpc.destroyService({ id })), prepare: call("prepare", (rpc) => rpc.prepareService({ id })), - status: call("status", (rpc) => rpc.status({ id }), false), + status: call("status", (rpc) => rpc.status({ id }), "attach"), followStatus: stream("followStatus", (rpc) => rpc.followStatus({ id })), logs: stream("logs", (rpc) => rpc.logs({ id })), credentials: (options) => @@ -462,9 +675,17 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( stdout: (bytes: Uint8Array) => Effect.Effect, stderr: (bytes: Uint8Array) => Effect.Effect, ) { + // A request that never reached this stack's owner is resent once, as `invoke` does. + let resend = false; return yield* Effect.scoped( Effect.gen(function* () { - const rpc = yield* client(true).pipe(Effect.mapError((cause) => failure("command", cause))); + resend = false; + let started = false; + const current = yield* borrow("launch").pipe( + Effect.provideContext(services), + Effect.mapError((cause) => failure("command", cause)), + ); + const { rpc } = current; const attachmentId = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => failure("command", cause)), ); @@ -483,6 +704,22 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( } : command; yield* rpc.runCommand({ attachmentId, command: encodedCommand }).pipe( + Stream.tapError((cause) => + ownerGone(cause) && !started + ? invalidate(current).pipe( + Effect.andThen( + Effect.sync(() => { + resend = true; + }), + ), + ) + : Effect.void, + ), + Stream.tap(() => + Effect.sync(() => { + started = true; + }), + ), Stream.mapError((cause) => failure("command", cause)), Stream.runForEach((event): Effect.Effect => Match.valueTags(event, { @@ -532,7 +769,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( return yield* failure("command", "Command attachment ended without an exit result"); return completed; }), - ); + ).pipe(Effect.retry({ times: 1, while: () => resend })); }); function run( command: PostgresCommand, @@ -610,7 +847,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( call("configureComposition", (rpc) => rpc.configureComposition(config)), describe: savedDefinition.pipe(Effect.map((current) => current.composition)), start: call("startComposition", (rpc) => rpc.startComposition()), - stop: call("stopComposition", (rpc) => rpc.stopComposition()), + stop: whileRunning("stopComposition", (rpc) => rpc.stopComposition(), []), restart: call("restartComposition", (rpc) => rpc.restartComposition()), }, stop: shutdown(false).pipe(Effect.asVoid), @@ -619,7 +856,10 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( } satisfies Stack; }); -/** Registers a stack; a matching existing identity must be opened explicitly. */ +/** + * Registers a stack; a matching existing identity must be opened explicitly. The handle's + * connections, and a session stack itself, last until the enclosing scope closes. + */ export const create = Effect.fn("Stack.create")( function* (options: CreateOptions) { const state = yield* stateFor(options.stateRoot); @@ -628,11 +868,36 @@ export const create = Effect.fn("Stack.create")( const saved: SavedStack = { id, identity, + lifetime: options.lifetime ?? "detached", runtime: options.runtime, instances: [], composition: { members: [], dependencies: [] }, ports: [], }; + const locations = { stateRoot: options.stateRoot, cacheRoot: options.cacheRoot }; + const existing = yield* state.read(id); + // A session stack whose owner is gone is disposable, so its identity is free again. + if (existing?.lifetime === "session" && !(yield* state.leased(id))) + yield* reclaimStack({ + state, + stateRoot: options.stateRoot, + cacheRoot: options.cacheRoot, + id, + }); + const session = saved.lifetime === "session"; + if (session || options.startOwner === true) { + if ((yield* state.read(id)) !== undefined) + return yield* failure("create", "Stack already exists; use open"); + // The owner registers the stack under its lease and removes it if its startup fails, so no + // sweep sees a session stack unowned and a failed launch leaves no registration behind. + const access = yield* launchHost(state, { + ...locations, + stackId: id, + register: saved, + lifeline: session, + }); + return yield* makeHandle(state, saved, locations, { access, creator: true }); + } yield* state.withLock( Effect.gen(function* () { if ((yield* state.read(id)) !== undefined) @@ -640,35 +905,7 @@ export const create = Effect.fn("Stack.create")( yield* state.save(saved); }), ); - // Keeps the stack when an owner holds it: one that reported ready before an interrupt, or one - // another caller launched after this registration was saved. - const rollback = state.withLock( - Effect.gen(function* () { - const current = yield* state.read(id); - if (current !== undefined && !(yield* controlPortHeld(current))) yield* state.remove(id); - }), - ); - if (options.startOwner) - yield* Effect.scoped(launchHost(state, { ...options, stackId: id })).pipe( - Effect.onInterrupt(() => rollback.pipe(Effect.ignore)), - Effect.matchEffect({ - onFailure: (launchError) => - rollback.pipe( - Effect.matchEffect({ - onFailure: (removeError) => - Effect.fail( - failure( - "create", - `${failure("create", launchError).message}; failed to remove stack ${id} after startup failure: ${failure("create", removeError).message}`, - ), - ), - onSuccess: () => Effect.fail(failure("create", launchError)), - }), - ), - onSuccess: () => Effect.void, - }), - ); - return yield* makeHandle(state, saved, options); + return yield* makeHandle(state, saved, locations); }, Effect.mapError((cause) => failure("create", cause)), ); @@ -679,9 +916,13 @@ export const open = Effect.fn("Stack.open")( const state = yield* stateFor(options.stateRoot); const saved = yield* state.read(options.id); if (saved === undefined) return yield* failure("open", "Stack does not exist"); - if (options.startOwner) - yield* Effect.scoped(launchHost(state, { ...options, stackId: saved.id })); - return yield* makeHandle(state, saved, options); + const locations = { stateRoot: options.stateRoot, cacheRoot: options.cacheRoot }; + const access = !options.startOwner + ? undefined + : saved.lifetime === "session" + ? yield* connectHost(state, saved.id) + : yield* launchHost(state, { ...locations, stackId: saved.id }); + return yield* makeHandle(state, saved, locations, { access }); }, Effect.mapError((cause) => failure("open", cause)), ); @@ -699,11 +940,11 @@ export const discover = Effect.fn("Stack.discover")( ), ); const saved = yield* state.list; - return yield* Effect.forEach(saved, (definition) => - connectHost(state, definition.id).pipe( - Effect.map((host) => ({ definition, host })), - Effect.catchTag("HostProcessError", () => Effect.succeed({ definition, host: undefined })), - ), + return yield* Effect.forEach( + saved, + (definition) => + observeHost(state, definition).pipe(Effect.map((host) => ({ definition, host }))), + { concurrency: 8 }, ); }, Effect.mapError((cause) => failure("discover", cause)), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 24cb83f647..88308bc279 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,5 +1,5 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Effect, Layer, ManagedRuntime, Schema, Stream } from "effect"; +import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect"; import * as StackEffect from "./effect.ts"; import { StackError } from "./Rpc.ts"; import { @@ -120,7 +120,7 @@ interface InternalCommandOptions extends CallOptions { readonly stderr?: (bytes: Uint8Array) => void | Promise; } -const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { +const adapt = (handle: StackEffect.Stack, runtime: Runtime, scope: Scope.Closeable) => { const run = (effect: Effect.Effect, options?: CallOptions) => runtime.runPromise(effect, options); const activeIterators = new Set<() => Promise>(); @@ -306,9 +306,9 @@ const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { destroy: (options?: CallOptions) => run(handle.destroy, options), close: () => { if (clientClosePromise !== undefined) return clientClosePromise; - clientClosePromise = Promise.allSettled( - [...activeIterators].map((dispose) => dispose()), - ).then(() => runtime.dispose()); + clientClosePromise = Promise.allSettled([...activeIterators].map((dispose) => dispose())) + .then(() => runtime.runPromise(Scope.close(scope, Exit.void))) + .then(() => runtime.dispose()); return clientClosePromise; }, commands: { run: runCommands }, @@ -359,7 +359,7 @@ const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { ); } }; -/** A Promise client whose close operation leaves the detached owner running. */ +/** A Promise client; closing the creating client of a session stack destroys the stack. */ export type Stack = ReturnType; const acquire = ( @@ -367,9 +367,14 @@ const acquire = ( options?: CallOptions, ): Promise => { const runtime = makeRuntime(); - return runtime.runPromise(effect, options).then( - (handle) => adapt(handle, runtime), - (error: unknown) => runtime.dispose().then(() => Promise.reject(error)), + const scope = runtime.runSync(Scope.make()); + return runtime.runPromise(effect.pipe(Scope.provide(scope)), options).then( + (handle) => adapt(handle, runtime, scope), + (error: unknown) => + runtime + .runPromise(Scope.close(scope, Exit.void)) + .then(() => runtime.dispose()) + .then(() => Promise.reject(error)), ); }; /** Registers a new stack identity. */ diff --git a/packages/stack/src/internal/dispatch.integration.test.ts b/packages/stack/src/internal/dispatch.integration.test.ts index 2d0008a0f5..7a547d4ef4 100644 --- a/packages/stack/src/internal/dispatch.integration.test.ts +++ b/packages/stack/src/internal/dispatch.integration.test.ts @@ -64,6 +64,7 @@ it.live("dispatches compiled owner and native launchers through the production b runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "compiled" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); diff --git a/packages/stack/src/internal/dispatch.ts b/packages/stack/src/internal/dispatch.ts index 8af8959b38..acd2ad7784 100644 --- a/packages/stack/src/internal/dispatch.ts +++ b/packages/stack/src/internal/dispatch.ts @@ -6,15 +6,7 @@ import { /** Runs the embedded owner when a compiled CLI receives its private dispatch marker. */ export const runHostProcessIfDispatched = (argv: ReadonlyArray): Promise => { if (argv[0] !== HOST_PROCESS_DISPATCH_SENTINEL) return Promise.resolve(false); - return import("./host-process.ts") - .then(({ runHostProcess }) => runHostProcess(argv.slice(1))) - .then( - () => true, - () => { - process.exitCode = 1; - return true; - }, - ); + return import("./host-process.ts").then(({ runHostProcess }) => runHostProcess(argv.slice(1))); }; /** Runs the embedded native launcher when a compiled CLI receives its private dispatch marker. */ diff --git a/packages/stack/src/internal/host-process.ts b/packages/stack/src/internal/host-process.ts index b70be51a23..eb459d0c37 100644 --- a/packages/stack/src/internal/host-process.ts +++ b/packages/stack/src/internal/host-process.ts @@ -1,15 +1,9 @@ -import { Cause, Effect, Option, Schema } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- readiness is an inherited launcher descriptor. import { closeSync, writeSync } from "node:fs"; +import { SavedStack } from "../State.ts"; import { runStackHost, StackHostError, type StackHostOptions } from "../StackHost.ts"; -const causeCode = (cause: unknown): string | undefined => { - if (typeof cause !== "object" || cause === null) return undefined; - if ("code" in cause && typeof cause.code === "string") return cause.code; - if ("cause" in cause) return causeCode(cause.cause); - return undefined; -}; - const writeLine = (value: unknown) => Effect.gen(function* () { const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( @@ -38,30 +32,37 @@ const writeLine = (value: unknown) => const options = ( args: ReadonlyArray, report: (value: unknown) => Effect.Effect, -): Effect.Effect => { - if (args.length !== 3) - return Effect.fail( - new StackHostError({ +): Effect.Effect => + Effect.gen(function* () { + const [stateRoot, cacheRoot, stackId, register, ...rest] = args; + if ( + stateRoot === undefined || + cacheRoot === undefined || + stackId === undefined || + rest.length > 0 + ) + return yield* new StackHostError({ operation: "startup", - message: "Expected stateRoot, cacheRoot and stackId", - }), - ); - const stateRoot = args[0]; - const cacheRoot = args[1]; - const stackId = args[2]; - if (stateRoot === undefined || cacheRoot === undefined || stackId === undefined) - return Effect.fail( - new StackHostError({ operation: "startup", message: "Missing host argument" }), - ); - return Effect.succeed({ - stateRoot, - cacheRoot, - stackId, - onReady: (endpoint) => report({ type: "ready", endpoint }), + message: "Expected stateRoot, cacheRoot, stackId and an optional stack to register", + }); + const registered = + register === undefined + ? undefined + : yield* Schema.decodeEffect(Schema.fromJsonString(SavedStack))(register).pipe( + Effect.mapError( + (cause) => new StackHostError({ operation: "startup", message: cause.message }), + ), + ); + return { + stateRoot, + cacheRoot, + stackId, + ...(registered === undefined ? {} : { register: registered }), + onReady: ({ endpoint, secret }) => report({ type: "ready", endpoint, secret }), + }; }); -}; -const program = (args: ReadonlyArray) => +const program = (args: ReadonlyArray, overrides: Pick) => Effect.gen(function* () { let reported = false; const report = (value: unknown) => @@ -71,30 +72,43 @@ const program = (args: ReadonlyArray) => return writeLine(value); }); const host = yield* options(args, report); - yield* runStackHost(host).pipe( + yield* runStackHost({ ...host, ...overrides }).pipe( Effect.catchCause((cause) => { const failure = Option.getOrUndefined(Cause.findErrorOption(cause)); - const reason = - causeCode(failure?.cause) === "EADDRINUSE" - ? "bind-conflict" - : failure?.reason === "runtime-unavailable" - ? "runtime-unavailable" - : undefined; return report({ type: "error", message: failure?.message ?? Cause.pretty(cause), - ...(reason === undefined ? {} : { reason }), + ...(failure?.reason === undefined ? {} : { reason: failure.reason }), }).pipe(Effect.exit, Effect.andThen(Effect.failCause(cause))); }), ); }); -/** Runs the owner process with its state root, artifact cache and stack identity. */ -export const runHostProcess = (args: ReadonlyArray): Promise => - Effect.runPromise(program(args)); +const flushed = (stream: NodeJS.WriteStream) => + Effect.callback((resume) => { + stream.write("", () => resume(Effect.void)); + }).pipe(Effect.timeoutOption("1 second")); -if (import.meta.main) { - void runHostProcess(process.argv.slice(2)).catch(() => { - process.exitCode = 1; - }); -} +/** + * Runs the owner process with its state root, artifact cache and stack identity, then exits the + * process once shutdown cleanup completes and the owner log is flushed, so no leftover handle + * delays the exit clients wait for. + */ +export const runHostProcess = ( + args: ReadonlyArray, + overrides: Pick = {}, +): Promise => + Effect.runPromise( + program(args, overrides).pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => process.stderr.write(`${Cause.pretty(exit.cause)}\n`)) + : Effect.void, + ), + Effect.tap(() => Effect.all([flushed(process.stdout), flushed(process.stderr)])), + Effect.flatMap((exit) => Effect.sync(() => process.exit(Exit.isSuccess(exit) ? 0 : 1))), + ), + ); + +if (import.meta.main) void runHostProcess(process.argv.slice(2)); diff --git a/packages/stack/src/internal/release.integration.test.ts b/packages/stack/src/internal/release.integration.test.ts new file mode 100644 index 0000000000..4ab004acbb --- /dev/null +++ b/packages/stack/src/internal/release.integration.test.ts @@ -0,0 +1,22 @@ +import { NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { sourceDigest } from "./release.ts"; + +it.live("digests only regular module files, ignoring dangling editor lock links", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-release-digest-" }); + yield* fs.makeDirectory(path.join(root, "nested")); + yield* fs.writeFileString(path.join(root, "nested", "Module.ts"), "export const a = 1;\n"); + const clean = yield* sourceDigest(root); + + yield* fs.symlink("user@host.1234:1700000000", path.join(root, "nested", ".#Module.ts")); + yield* fs.makeDirectory(path.join(root, "folder.ts")); + + expect(yield* sourceDigest(root)).toBe(clean); + yield* fs.writeFileString(path.join(root, "nested", "Module.ts"), "export const a = 2;\n"); + expect(yield* sourceDigest(root)).not.toBe(clean); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/internal/release.ts b/packages/stack/src/internal/release.ts new file mode 100644 index 0000000000..9539d4d243 --- /dev/null +++ b/packages/stack/src/internal/release.ts @@ -0,0 +1,50 @@ +import { Crypto, Effect, FileSystem, Path } from "effect"; +import effectPackage from "effect/package.json" with { type: "json" }; +import { fileURLToPath } from "node:url"; + +const hex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +/** Digests the regular `.ts` modules under `sourceRoot`, skipping symlinks and other entries. */ +export const sourceDigest = Effect.fn("Release.sourceDigest")(function* (sourceRoot: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const regularFile = (file: string) => + fs.readLink(path.join(sourceRoot, file)).pipe( + Effect.as(false), + Effect.catch(() => + fs.stat(path.join(sourceRoot, file)).pipe(Effect.map((info) => info.type === "File")), + ), + ); + const modules = yield* Effect.filter( + (yield* fs.readDirectory(sourceRoot, { recursive: true })) + .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts")) + .toSorted(), + regularFile, + ); + const encoder = new TextEncoder(); + const parts: Array = [encoder.encode(`effect@${effectPackage.version}\0`)]; + for (const file of modules) + parts.push( + encoder.encode(`${file}\0`), + yield* fs.readFile(path.join(sourceRoot, file)), + encoder.encode("\0"), + ); + const content = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + for (const part of parts) { + content.set(part, offset); + offset += part.length; + } + return hex(yield* crypto.digest("SHA-256", content)).slice(0, 16); +}); + +/** + * Digests this package's module sources and its Effect version, which together determine the + * owner's behavior and wire protocol. Source runs and CLI builds of the same sources agree on it. + */ +export const stackSourceDigest = Effect.gen(function* () { + const path = yield* Path.Path; + return yield* sourceDigest(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); +}); diff --git a/packages/stack/src/lifetime.integration.test.ts b/packages/stack/src/lifetime.integration.test.ts new file mode 100644 index 0000000000..da1a17a058 --- /dev/null +++ b/packages/stack/src/lifetime.integration.test.ts @@ -0,0 +1,224 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { + Context, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + Schema, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { create, open } from "./effect.ts"; +import * as State from "./State.ts"; +import { assertOwnerExited, captureOwnerPid, watchLeaseRelease } from "../tests/owner.ts"; + +class LifetimeTestError extends Data.TaggedError("LifetimeTestError")<{ + readonly message: string; +}> {} + +const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; +const sessionFixture = fileURLToPath( + new URL("../tests/session-client-fixture.ts", import.meta.url), +); + +const stateFor = (root: string) => + Layer.build(State.layer({ root })).pipe( + Effect.map((context) => Context.get(context, State.Service)), + ); + +const collect = (child: ChildProcessSpawner.ChildProcessHandle) => + Effect.all( + [ + child.stdout.pipe(Stream.decodeText, Stream.mkString), + child.stderr.pipe(Stream.decodeText, Stream.mkString), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + +/** Lists the live descendants of `pid` from the process table. */ +const descendantsOf = Effect.fn("LifetimeTest.descendantsOf")(function* (pid: number) { + const [table] = yield* Effect.scoped( + ChildProcess.make("ps", ["-axo", "pid=,ppid="], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe(Effect.flatMap(collect)), + ); + const children = new Map>(); + for (const line of table.split("\n")) { + const [child, parent] = line.trim().split(/\s+/u).map(Number); + if (child === undefined || parent === undefined || Number.isNaN(child)) continue; + children.set(parent, [...(children.get(parent) ?? []), child]); + } + const found: Array = []; + const pending = [pid]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const direct = children.get(next) ?? []; + found.push(...direct); + pending.push(...direct); + } + return found; +}); + +const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Completes once `entry` disappears from `directory`; subscribe before triggering the removal. */ +const awaitRemoval = (directory: string, entry: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(directory, entry); + yield* fs.watch(directory).pipe( + Stream.filter((event) => event.path === entry || event.path === target), + Stream.mapEffect(() => fs.exists(target)), + Stream.takeUntil((exists) => !exists), + Stream.runDrain, + ); + }); + +it.live.skipIf(process.platform === "win32")( + "destroys a session stack and its native processes when the creating process is killed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-kill-" }); + const stateRoot = `${root}/state`; + const creator = yield* ChildProcess.make( + process.execPath, + [sessionFixture, root, cacheRoot], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); + const stderr = yield* creator.stderr.pipe( + Stream.decodeText, + Stream.mkString, + Effect.forkScoped, + ); + const ready = yield* creator.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runHead, + Effect.timeout("2 minutes"), + Effect.flatMap( + Option.match({ + onNone: () => + Fiber.join(stderr).pipe( + Effect.flatMap((diagnostics) => + Effect.fail( + new LifetimeTestError({ + message: `Session creator exited before readiness: ${diagnostics}`, + }), + ), + ), + ), + onSome: Effect.succeed, + }), + ), + ); + const { stackId } = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ stackId: Schema.String })), + )(ready); + const state = yield* stateFor(stateRoot); + expect(yield* state.leased(stackId)).toBe(true); + expect((yield* state.claims).find(({ id }) => id === stackId)?.ports.length).toBeGreaterThan( + 0, + ); + const ownerPid = yield* captureOwnerPid({ stateRoot, cacheRoot }, stackId); + const owned = yield* descendantsOf(ownerPid); + expect(owned.length, "the owner runs the native mail service").toBeGreaterThan(0); + + const removed = yield* awaitRemoval(stateRoot, stackId).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const released = yield* watchLeaseRelease(stateRoot, stackId); + yield* creator.kill({ killSignal: "SIGKILL" }); + yield* Fiber.join(removed).pipe( + Effect.timeoutOrElse({ + duration: "1 minute", + orElse: () => + Effect.fail(new LifetimeTestError({ message: "Session stack was not destroyed" })), + }), + ); + + // Releasing the lease is the owner's last act, after its native processes have exited. + yield* released; + expect(owned.filter(alive), "native processes die with their owner").toEqual([]); + expect(yield* fs.exists(`${stateRoot}/${stackId}`)).toBe(false); + expect((yield* state.claims).some(({ id }) => id === stackId)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), + { timeout: 180_000 }, +); + +it.live("destroys a session stack when its creating handle closes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-close-" }); + const locations = { stateRoot: `${root}/state`, cacheRoot }; + const { id, ownerPid } = yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* create({ + ...locations, + projectRoot: root, + runtime: "native", + lifetime: "session", + }); + expect(yield* stack.composition.start).toEqual([]); + return { id: stack.id, ownerPid: yield* captureOwnerPid(locations, stack.id) }; + }), + ); + yield* assertOwnerExited(ownerPid); + const state = yield* stateFor(locations.stateRoot); + expect(yield* state.read(id)).toBeUndefined(); + expect(yield* fs.exists(`${locations.stateRoot}/${id}`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("lets only the creating handle start a session stack's owner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-attach-" }); + const locations = { stateRoot: `${root}/state`, cacheRoot }; + const id = yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* create({ + ...locations, + projectRoot: root, + runtime: "native", + lifetime: "session", + }); + const other = yield* open({ ...locations, id: stack.id }); + expect(yield* other.composition.start).toEqual([]); + yield* other.stop; + + const refused = yield* Effect.flip(other.composition.start); + expect(refused.message).toContain("has no live owner"); + + expect(yield* stack.composition.start).toEqual([]); + expect(yield* other.composition.stop).toEqual([]); + return stack.id; + }), + ); + const state = yield* stateFor(locations.stateRoot); + expect(yield* state.read(id)).toBeUndefined(); + expect(yield* state.leased(id)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/services/Rest.integration.test.ts b/packages/stack/src/services/Rest.integration.test.ts index 01290e532a..a8d87db95f 100644 --- a/packages/stack/src/services/Rest.integration.test.ts +++ b/packages/stack/src/services/Rest.integration.test.ts @@ -57,6 +57,7 @@ describe("service catalog", () => { identity: { projectRoot: root, branchContext: "test", stackName: "catalog" }, runtime: "docker", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); diff --git a/packages/stack/src/shutdown.integration.test.ts b/packages/stack/src/shutdown.integration.test.ts index b06d1c5405..905d95750c 100644 --- a/packages/stack/src/shutdown.integration.test.ts +++ b/packages/stack/src/shutdown.integration.test.ts @@ -58,13 +58,14 @@ const withHeldOwner = ( runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "shutdown-held" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return yield* Effect.acquireUseRelease( spawner.spawn( - ChildProcess.make(process.execPath, [fixturePath, stateRoot, cacheRoot, id, "rpc-held"], { + ChildProcess.make(process.execPath, [fixturePath, stateRoot, cacheRoot, id, "held"], { cwd: process.cwd(), detached: true, stdin: "ignore", diff --git a/packages/stack/tests/compiled-dispatch-fixture.ts b/packages/stack/tests/compiled-dispatch-fixture.ts index 7f3b1d60d6..2c34f98a4b 100644 --- a/packages/stack/tests/compiled-dispatch-fixture.ts +++ b/packages/stack/tests/compiled-dispatch-fixture.ts @@ -1,25 +1,21 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { Data, Effect, Layer, Option, Stream } from "effect"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import { runHostProcessIfDispatched, runNativeProcessIfDispatched, } from "../src/internal/dispatch.ts"; -import { connectHost, launchHost, type HostEndpoint } from "../src/HostProcess.ts"; +import { + connectHost, + launchHost, + shutdownHost, + waitForOwnerExit, + type HostEndpoint, +} from "../src/HostProcess.ts"; import { spawnNativeProcess } from "../src/runtime/NativeProcess.ts"; -import { StackRpc } from "../src/Rpc.ts"; import * as State from "../src/State.ts"; const argv = process.argv.slice(2); class FixtureError extends Data.TaggedError("FixtureError")<{ readonly message: string }> {} -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); if (!(await runHostProcessIfDispatched(argv)) && !(await runNativeProcessIfDispatched(argv))) { const [mode, ...modeArgs] = argv; const output = (endpoint: HostEndpoint) => process.stdout.write(`${JSON.stringify(endpoint)}\n`); @@ -31,14 +27,16 @@ if (!(await runHostProcessIfDispatched(argv)) && !(await runNativeProcessIfDispa const program = Effect.scoped( Effect.gen(function* () { const state = yield* State.Service; - const endpoint = yield* mode === "owner" + const access = yield* mode === "owner" ? launchHost(state, { stateRoot, cacheRoot: cacheRoot ?? stateRoot, stackId }) : connectHost(state, stackId); if (mode === "stop") { - const client = yield* clientFor(endpoint.port); - yield* client.shutdown({ destroy: false }); + const refusal = yield* shutdownHost(access, false); + if (Option.isSome(refusal)) + return yield* new FixtureError({ message: refusal.value.message }); + yield* waitForOwnerExit(access.endpoint.pid); } - output(endpoint); + output(access.endpoint); }), ).pipe( Effect.provide( diff --git a/packages/stack/tests/failing-owner-fixture.ts b/packages/stack/tests/failing-owner-fixture.ts new file mode 100644 index 0000000000..86e800a580 --- /dev/null +++ b/packages/stack/tests/failing-owner-fixture.ts @@ -0,0 +1,24 @@ +import { Effect, Schema } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the fixture reports on the inherited readiness descriptor. +import { closeSync, rmSync, writeSync } from "node:fs"; + +const [stateRoot, , stackId] = process.argv.slice(2); +if (stateRoot === undefined || stackId === undefined) throw new Error("Fixture arguments missing"); + +/** Logs a diagnostic, deletes its own log as a failed session start does, then reports failure. */ +const program = Effect.gen(function* () { + process.stderr.write("failing-owner-diagnostic\n"); + rmSync(`${stateRoot}/${stackId}/owner.log`, { force: true }); + const line = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + type: "error", + message: "owner startup failed", + }); + try { + writeSync(3, `${line}\n`); + } finally { + closeSync(3); + } + process.exitCode = 1; +}); + +await Effect.runPromise(program); diff --git a/packages/stack/tests/host-process-fixture.ts b/packages/stack/tests/host-process-fixture.ts index c35995a6e2..f0210334c5 100644 --- a/packages/stack/tests/host-process-fixture.ts +++ b/packages/stack/tests/host-process-fixture.ts @@ -1,28 +1,25 @@ import { NodeHttpClient, NodeServices, NodeStream } from "@effect/platform-node"; -import { Context, Data, Deferred, Effect, FileSystem, Layer, Path, Schema, Stream } from "effect"; +import { + Context, + Data, + DateTime, + Deferred, + Effect, + FileSystem, + Layer, + Path, + Schema, + Stream, +} from "effect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { Rpc, RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test fixture owns inherited readiness and release descriptors. import { closeSync, createReadStream, writeSync } from "node:fs"; -import { acquireHost, HostEndpoint, launchHost } from "../src/HostProcess.ts"; +import { currentRelease, launchHost, authorizes, type HostEndpoint } from "../src/HostProcess.ts"; +import { bindControl } from "../src/StackHost.ts"; import * as State from "../src/State.ts"; class FixtureError extends Data.TaggedError("FixtureError")<{ readonly message: string }> {} -const FixtureRpc = RpcGroup.make( - Rpc.make("shutdown", { - payload: { destroy: Schema.Boolean }, - error: Schema.Never, - }), -); - -const causeCode = (cause: unknown): string | undefined => { - if (typeof cause !== "object" || cause === null) return undefined; - if ("code" in cause && typeof cause.code === "string") return cause.code; - if ("cause" in cause) return causeCode(cause.cause); - return undefined; -}; - const makeState = (root: string) => Layer.build(State.layer({ root })).pipe( Effect.map((context) => Context.get(context, State.Service)), @@ -33,10 +30,25 @@ const awaitBarrier = NodeStream.fromReadable({ onError: (cause) => new FixtureError({ message: String(cause) }), }).pipe(Stream.take(1), Stream.runDrain); +let reported = false; +const report = (value: unknown) => { + if (reported) return; + reported = true; + try { + writeSync( + 3, + Buffer.from(`${Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(value)}\n`), + ); + } finally { + closeSync(3); + } +}; + const [stateRoot, cacheRoot, stackId, mode = "owner", ownerEntrypoint] = process.argv.slice(2); if (stateRoot === undefined || stackId === undefined) throw new FixtureError({ message: "fixture arguments missing" }); +/** Follows the owner protocol without services; `held` acknowledges shutdown but stays alive. */ const owner = Effect.scoped( Effect.gen(function* () { const state = yield* makeState(stateRoot); @@ -44,96 +56,84 @@ const owner = Effect.scoped( const path = yield* Path.Path; const stack = yield* state.read(stackId); if (stack === undefined) return yield* new FixtureError({ message: "stack is not registered" }); - const host = yield* acquireHost(state, stackId); + if (!(yield* state.lease(stackId))) { + report({ type: "error", message: "lease held", reason: "lease-held" }); + return; + } + yield* state.retractHolder(stackId); if (stack.identity.stackName === "slow-handshake") { const marker = path.join(stateRoot, "slow-handshake.pid"); yield* fs.writeFileString(`${marker}.tmp`, String(process.pid)); yield* fs.rename(`${marker}.tmp`, marker); return yield* Effect.never; } + const control = yield* bindControl(); const shutdown = yield* Deferred.make(); const endpoint: HostEndpoint = { stackId, identity: stack.identity, pid: process.pid, - port: host.port, + port: control.port, + release: yield* currentRelease, }; - const rpc = yield* RpcServer.toHttpEffect(FixtureRpc, { streamBufferSize: 4 }).pipe( - Effect.provide( - Layer.merge( - FixtureRpc.toLayer({ - shutdown: () => Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid), - }), - RpcSerialization.layerNdjson, - ), - ), - ); - const serving = host.server.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method === "GET" && request.url === "/identity") - return yield* HttpServerResponse.json(endpoint).pipe( - Effect.map(HttpServerResponse.setHeader("connection", "close")), - ); - if (request.method === "POST" && request.url === "/shutdown") { - yield* Deferred.succeed(shutdown, undefined); - return HttpServerResponse.empty({ status: 202 }).pipe( - HttpServerResponse.setHeader("connection", "close"), - ); - } - if (mode === "rpc-held" && request.method === "POST" && request.url.startsWith("/rpc")) - return yield* rpc; - return HttpServerResponse.empty({ status: 404 }); - }), - ); - yield* serving.pipe(Effect.forkScoped); - try { - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ - type: "ready", - endpoint, - }); - writeSync(3, Buffer.from(`${encoded}\n`)); - } finally { - closeSync(3); - } - if (mode === "rpc-held") yield* awaitBarrier; + const secret = `fixture-${process.pid}`; + yield* control.server + .serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (!authorizes(request.headers.authorization, secret)) + return HttpServerResponse.empty({ status: 401 }); + if (request.method === "GET" && request.url === "/identity") + return yield* HttpServerResponse.json(endpoint).pipe( + Effect.map(HttpServerResponse.setHeader("connection", "close")), + ); + if (request.method === "POST" && request.url === "/shutdown") { + yield* Deferred.succeed(shutdown, undefined); + return HttpServerResponse.empty({ status: 204 }).pipe( + HttpServerResponse.setHeader("connection", "close"), + ); + } + return HttpServerResponse.empty({ status: 404 }); + }), + ) + .pipe(Effect.forkScoped); + yield* state.publishHolder(stackId, { + role: "owner", + secret, + port: endpoint.port, + pid: endpoint.pid, + release: endpoint.release, + lifetime: stack.lifetime, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + report({ type: "ready", endpoint, secret }); + if (mode === "held") yield* awaitBarrier; else yield* Deferred.await(shutdown); + yield* state.retractHolder(stackId); }), ); -const launcher = Effect.gen(function* () { - const state = yield* makeState(stateRoot); - const endpoint = yield* launchHost(state, { - stateRoot, - cacheRoot: cacheRoot ?? stateRoot, - stackId, - entrypoint: ownerEntrypoint ?? new URL(import.meta.url).pathname, - }); - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(endpoint); - process.stdout.write(`${encoded}\n`); -}); +const launcher = Effect.scoped( + Effect.gen(function* () { + const state = yield* makeState(stateRoot); + const { endpoint } = yield* launchHost(state, { + stateRoot, + cacheRoot: cacheRoot ?? stateRoot, + stackId, + entrypoint: ownerEntrypoint ?? new URL(import.meta.url).pathname, + }); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(endpoint); + process.stdout.write(`${encoded}\n`); + }), +); -const program = mode === "launcher" ? launcher : owner; try { - await Effect.runPromise( - Effect.scoped(program).pipe( - Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), - ), - ); + const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + if (mode === "launcher") await Effect.runPromise(launcher.pipe(Effect.provide(layer))); + else await Effect.runPromise(owner.pipe(Effect.provide(layer))); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); - const reason = causeCode(cause) === "EADDRINUSE" ? "bind-conflict" : undefined; - const error = `${Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))({ - type: "error", - message, - ...(reason === undefined ? {} : { reason }), - })}\n`; - if (mode === "owner") { - try { - writeSync(3, Buffer.from(error)); - } finally { - closeSync(3); - } - } else process.stderr.write(error); + if (mode === "launcher") process.stderr.write(`${message}\n`); + else report({ type: "error", message }); process.exitCode = 1; } diff --git a/packages/stack/tests/lease-wait-fixture.ts b/packages/stack/tests/lease-wait-fixture.ts new file mode 100644 index 0000000000..f12bf25774 --- /dev/null +++ b/packages/stack/tests/lease-wait-fixture.ts @@ -0,0 +1,34 @@ +import { Data, Effect, Predicate } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the waiter reports on stdout synchronously before it blocks on the lock. +import { writeSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the waiter takes the same SQLite lock as the lease. +import { DatabaseSync } from "node:sqlite"; +import { pathToFileURL } from "node:url"; + +class LeaseWaitError extends Data.TaggedError("LeaseWaitError")<{ readonly message: string }> {} + +const [file] = process.argv.slice(2); +if (file === undefined) throw new Error("Lease file missing"); + +/** Opens an existing lease file, reports `waiting`, then blocks until its holder releases it. */ +const program = Effect.try({ + try: () => { + const connection = new DatabaseSync(new URL(`${pathToFileURL(file).href}?mode=rw`)); + writeSync(1, "waiting\n"); + connection.exec("PRAGMA busy_timeout = 120000"); + try { + connection.exec("BEGIN IMMEDIATE"); + } catch (cause) { + // A lock file its releasing holder unlinked: IOERR_VNODE on macOS, IOERR_FSTAT on Linux. + if ( + !(Predicate.hasProperty(cause, "errcode") && [6922, 1802].includes(Number(cause.errcode))) + ) + throw cause; + } + connection.close(); + writeSync(1, "free\n"); + }, + catch: (cause) => new LeaseWaitError({ message: String(cause) }), +}); + +await Effect.runPromise(program); diff --git a/packages/stack/tests/lingering-owner-fixture.ts b/packages/stack/tests/lingering-owner-fixture.ts new file mode 100644 index 0000000000..d2175732fb --- /dev/null +++ b/packages/stack/tests/lingering-owner-fixture.ts @@ -0,0 +1,6 @@ +import { runHostProcess } from "../src/internal/host-process.ts"; + +// oxlint-disable-next-line effecttsgo/global-timers -- a raw open handle stands in for a leaked timer or socket. +setInterval(() => undefined, 60_000); + +if (import.meta.main) await runHostProcess(process.argv.slice(2)); diff --git a/packages/stack/tests/owner.ts b/packages/stack/tests/owner.ts index a295b375ab..1678d75ab4 100644 --- a/packages/stack/tests/owner.ts +++ b/packages/stack/tests/owner.ts @@ -1,7 +1,66 @@ import { expect } from "@effect/vitest"; -import { Effect, Predicate } from "effect"; +import { Deferred, Effect, Fiber, Option, Predicate, Stream } from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { fileURLToPath } from "node:url"; import { discover, type StackLocations } from "../src/effect.ts"; -import { HostProcessError } from "../src/HostProcess.ts"; +import { HostProcessError, shutdownHost, type HostAccess } from "../src/HostProcess.ts"; + +/** Requests shutdown through the owner's stable endpoint, failing with the owner's refusal. */ +export const shutdownOwner = Effect.fn("Test.shutdownOwner")(function* ( + access: HostAccess, + destroy: boolean, +) { + const refusal = yield* shutdownHost(access, destroy); + if (Option.isSome(refusal)) return yield* Effect.fail(refusal.value); +}); + +const leaseWaiter = fileURLToPath(new URL("./lease-wait-fixture.ts", import.meta.url)); + +/** + * Starts waiting on a stack's lease file and returns an effect that completes once its current + * holder releases it; call it before triggering the release. + */ +export const watchLeaseRelease = Effect.fn("Test.watchLeaseRelease")(function* ( + stateRoot: string, + id: string, +) { + const waiter = yield* ChildProcess.make( + process.execPath, + [leaseWaiter, `${stateRoot}/${id}/owner.lock`], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + const waiting = yield* Deferred.make(); + const stderr = yield* Stream.decodeText(waiter.stderr).pipe(Stream.mkString, Effect.forkScoped); + const lines = yield* Stream.decodeText(waiter.stdout).pipe( + Stream.splitLines, + Stream.tap((line) => (line === "waiting" ? Deferred.succeed(waiting, undefined) : Effect.void)), + Stream.runCollect, + Effect.forkScoped, + ); + const fail = (message: string) => + Fiber.join(stderr).pipe( + Effect.flatMap((diagnostics) => + Effect.fail( + new HostProcessError({ operation: "test-lease", message: `${message}: ${diagnostics}` }), + ), + ), + ); + const output = Fiber.join(lines).pipe(Effect.map((collected) => Array.from(collected))); + yield* Deferred.await(waiting).pipe( + Effect.raceFirst(output.pipe(Effect.andThen(fail("Lease waiter did not open the lease")))), + ); + return yield* Effect.succeed( + output.pipe( + Effect.flatMap((collected) => + collected.includes("free") ? Effect.void : fail("Lease waiter exited before the release"), + ), + Effect.timeoutOrElse({ + duration: "2 minutes", + orElse: () => fail("The lease was not released"), + }), + ), + ); +}); export const captureOwnerPid = Effect.fn("Test.captureOwnerPid")(function* ( locations: StackLocations, diff --git a/packages/stack/tests/release-owner-fixture.ts b/packages/stack/tests/release-owner-fixture.ts new file mode 100644 index 0000000000..065d953cbd --- /dev/null +++ b/packages/stack/tests/release-owner-fixture.ts @@ -0,0 +1,6 @@ +import { runHostProcess } from "../src/internal/host-process.ts"; + +/** The release this owner reports, which no client build matches. */ +export const foreignRelease = "0.0.0-foreign"; + +if (import.meta.main) await runHostProcess(process.argv.slice(2), { release: foreignRelease }); diff --git a/packages/stack/tests/session-client-fixture.ts b/packages/stack/tests/session-client-fixture.ts new file mode 100644 index 0000000000..aa7b2020c8 --- /dev/null +++ b/packages/stack/tests/session-client-fixture.ts @@ -0,0 +1,35 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { Effect, Layer, Schema } from "effect"; +import { create } from "../src/effect.ts"; + +const [root, cacheRoot] = process.argv.slice(2); +if (root === undefined || cacheRoot === undefined) throw new Error("Session fixture roots missing"); + +/** Creates a session stack with a running native service, reports it, and waits to be killed. */ +const program = Effect.scoped( + Effect.gen(function* () { + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot, + runtime: "native", + lifetime: "session", + }); + const mail = yield* stack.services.create({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + yield* mail.start; + yield* mail.ready; + const ready = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Struct({ stackId: Schema.String })), + )({ stackId: stack.id }); + process.stdout.write(`${ready}\n`); + return yield* Effect.never; + }), +); + +await Effect.runPromise( + program.pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index 6969c2bbff..370ba72188 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -16,7 +16,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { compileOptions } from "../../apps/cli/scripts/compile-options.ts"; +import { compileOptions, stackReleaseDefine } from "../../apps/cli/scripts/compile-options.ts"; const PORT = 4873; const REGISTRY = `http://localhost:${PORT}`; @@ -187,6 +187,7 @@ async function main() { entrypoints: [entrypoint], compile: { target: platform.bunTarget, outfile: bunBinary }, ...compileOptions, + define: await stackReleaseDefine(), }); for (const log of buildResult.logs) { console.warn(log); diff --git a/turbo.json b/turbo.json index 3487f7723d..fcbaf2b8c3 100644 --- a/turbo.json +++ b/turbo.json @@ -60,7 +60,9 @@ "$TURBO_DEFAULT$", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock", - "$TURBO_ROOT$/packages/api/**" + "$TURBO_ROOT$/packages/api/**", + "$TURBO_ROOT$/packages/stack/package.json", + "$TURBO_ROOT$/packages/stack/src/**" ], "outputs": ["dist/**"] }, From 7cd1a79d0581f4160342913db94bfb0d8f1f3400 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 13:44:52 +0000 Subject: [PATCH 15/71] feat(stack): ship the functions bootstrap with the package (#6839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The Functions recipe required every caller to pass the bundled edge-runtime main-service source. The CLI's build, the CLI's stack runtime and the package's tests each bundled the package's own `serve.main.ts` with esbuild. The full bundle was also persisted in every saved Functions creation. ## Change - Generate the bundle into a committed module with a drift test, wired into `pnpm generate`. The bundle is self-contained, so it works offline. - Make the Functions `bootstrap` input optional; the package default is never persisted. - Delete the CLI's stack bootstrap bundler, its build define, and the internal serve-main export. The legacy backend's bootstrap is untouched. ## Stack Part 6 of 8 of the stack package simplification, based on #6838. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package ← this PR 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API --------- Co-authored-by: Claude Opus 5.5 --- .gitattributes | 1 + .oxfmtrc.json | 1 + apps/cli/scripts/build-binary.ts | 5 -- apps/cli/scripts/build.ts | 5 -- apps/cli/src/command-internal/stack-config.ts | 25 +----- .../stack-functions-bundler.ts | 42 ---------- .../experimental/stack/status/SIDE_EFFECTS.md | 2 - .../functions/serve/serve.stack.e2e.test.ts | 4 - .../telemetry/__fixtures__/error-tags.txt | 1 - knip.jsonc | 8 +- package.json | 2 +- packages/stack/ARCHITECTURE.md | 2 +- packages/stack/README.md | 2 +- packages/stack/package.json | 2 +- .../scripts/generate-functions-bootstrap.ts | 76 +++++++++++++++++++ .../generate-functions-bootstrap.unit.test.ts | 24 ++++++ .../functions/generated/serve-main-bundle.ts | 3 + .../serve-main-bundler.integration.test.ts | 10 +-- .../services/Functions.integration.test.ts | 5 -- packages/stack/src/services/Functions.ts | 5 +- packages/stack/tests/serve-main-bundler.ts | 45 ----------- packages/stack/tests/whole-stack/fixture.ts | 4 +- turbo.json | 4 + 23 files changed, 130 insertions(+), 148 deletions(-) delete mode 100644 apps/cli/src/command-internal/stack-functions-bundler.ts create mode 100644 packages/stack/scripts/generate-functions-bootstrap.ts create mode 100644 packages/stack/scripts/generate-functions-bootstrap.unit.test.ts create mode 100644 packages/stack/src/functions/generated/serve-main-bundle.ts delete mode 100644 packages/stack/tests/serve-main-bundler.ts diff --git a/.gitattributes b/.gitattributes index 1f199bbbdb..7f30866cca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,6 +26,7 @@ apps/cli-go/api/v1-openapi.yaml linguist-generated=true apps/cli-go/pkg/api/*.gen.go linguist-generated=true packages/api/src/generated/* linguist-generated=true packages/api/scripts/openapi-source.json linguist-generated=true +packages/stack/src/functions/generated/* text eol=lf linguist-generated=true apps/cli/src/shared/feedback/database.types.ts linguist-generated=true apps/docs/public/cli/config.schema.json linguist-generated=true apps/docs/public/cli/project-config.schema.json linguist-generated=true diff --git a/.oxfmtrc.json b/.oxfmtrc.json index cdddd6ab52..e364381a7e 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -2,6 +2,7 @@ "ignorePatterns": [ ".repos/", "apps/cli/src/shared/feedback/database.types.ts", + "packages/stack/src/functions/generated/", "apps/cli-go/", "apps/cli-e2e/fixtures/", "apps/docs/content/docs/commands/", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index d37cc6dc8e..f2dcb02b00 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,6 +1,4 @@ import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; -import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; -import { Effect } from "effect"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; /** @@ -26,9 +24,6 @@ const result = await Bun.build({ SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), ...(await stackReleaseDefine()), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), - SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( - await Effect.runPromise(bundleStackFunctionsServeMainTemplate()), - ), // Skips msgpackr's native addon probe at the build host's path, which can hang macOS startup. "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": JSON.stringify("true"), }, diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index c3173519ea..a924c87494 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -4,9 +4,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; -import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -92,9 +90,6 @@ const goSource = path.resolve(root, "apps/cli-go"); const buildDefines = { ...(await stackReleaseDefine()), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), - SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( - await Effect.runPromise(bundleStackFunctionsServeMainTemplate()), - ), "process.env.SUPABASE_CLI_POSTHOG_KEY": JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), "process.env.SUPABASE_CLI_POSTHOG_HOST": JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), // Skips msgpackr's startup probe for its native addon at the build host's store path, which diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 53455e45e3..300fdb4e75 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -12,7 +12,6 @@ import { resolveAuthConfig } from "./stack-auth-config.ts"; import { parseGoDuration } from "./go-duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; -declare const SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; import { decryptAuthSecret, resolveJwtSecret, @@ -1136,26 +1135,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const createCreations = ( stackId: string, ): Effect.Effect, StackConfigError> => - Effect.gen(function* () { - const bootstrap = validatedConfig.edge_runtime.enabled - ? yield* typeof SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" - ? Effect.succeed(SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE) - : Effect.tryPromise({ - try: () => import("./stack-functions-bundler.ts"), - catch: (cause) => - new StackConfigError({ - message: `Unable to load Functions bundler: ${String(cause)}`, - }), - }).pipe( - Effect.flatMap(({ bundleStackFunctionsServeMainTemplate }) => - bundleStackFunctionsServeMainTemplate().pipe( - Effect.mapError( - (cause) => new StackConfigError({ message: cause.message }), - ), - ), - ), - ) - : undefined; + Effect.sync(() => { return [ { service: "database", @@ -1304,7 +1284,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( } satisfies ServiceCreationType, ] : []), - ...(validatedConfig.edge_runtime.enabled && bootstrap !== undefined + ...(validatedConfig.edge_runtime.enabled ? [ { service: "functions" as const, @@ -1313,7 +1293,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( filesRoot: projectRoot, functions, env: functionsEnv, - bootstrap, policy: validatedConfig.edge_runtime.policy, verifyJwt: true, ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), diff --git a/apps/cli/src/command-internal/stack-functions-bundler.ts b/apps/cli/src/command-internal/stack-functions-bundler.ts deleted file mode 100644 index e2a44e6b21..0000000000 --- a/apps/cli/src/command-internal/stack-functions-bundler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import { build } from "esbuild"; -import { Data, Effect } from "effect"; - -export class StackFunctionsBundleError extends Data.TaggedError("StackFunctionsBundleError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -/** Bundles the stack runtime's Functions entrypoint for an offline native stack. */ -export const bundleStackFunctionsServeMainTemplate = Effect.fn( - "StackFunctionsBundler.bundleServeMainTemplate", -)(function* () { - const entrypoint = fileURLToPath( - import.meta.resolve("@supabase/stack/internal/functions/serve-main"), - ); - const result = yield* Effect.tryPromise({ - try: () => - build({ - entryPoints: [entrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", - }), - catch: (cause) => - new StackFunctionsBundleError({ - message: "Unable to bundle the stack Functions runtime", - cause, - }), - }); - const output = result.outputFiles[0]?.text; - if (output === undefined) - return yield* new StackFunctionsBundleError({ - message: "esbuild produced no stack Functions template", - }); - return output; -}); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index d22a0e8997..e838fb9aaf 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -31,8 +31,6 @@ available and is shown as `config_drift.status: "unavailable"` with a message in JSON. Status does not apply current configuration. The `services` list may include saved standalone instances; composition members identify the services used for primary database, environment export, and drift comparisons. -The source checkout may bundle the default Functions bootstrap while translating -project configuration for drift; this is in-memory and writes no project files. Text output includes identity, runtime, owner, lifecycle, readiness, services, endpoints, and config drift. JSON nests only identity fields under `identity`; diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 8712a8089b..6b2a157576 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; -import { bundleStackFunctionsServeMainTemplate } from "../../../command-internal/stack-functions-bundler.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; @@ -66,7 +65,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( runtime, }); yield* Effect.addFinalizer(() => destroyTestStack(stack)); - const bootstrap = yield* bundleStackFunctionsServeMainTemplate(); yield* stack.composition.supabase([ { service: "database", @@ -89,7 +87,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( service: "functions" as const, config: { functionsRoot, - bootstrap, jwtSecret, verifyJwt: true, env: { CUSTOM_VALUE: "original" }, @@ -116,7 +113,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( service: "functions", config: { functionsRoot, - bootstrap, jwtSecret, verifyJwt: true, env: { CUSTOM_VALUE: "excluded" }, diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 017742dd1f..6c8d8301ff 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -526,7 +526,6 @@ SsoUpdateUnexpectedStatusError StackBootstrapError StackCatalogSetupError StackConfigError -StackFunctionsBundleError StackFunctionsEnvError StackNativeEngineError StackRoutingError diff --git a/knip.jsonc b/knip.jsonc index 524d79f80d..c1edba7619 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -57,8 +57,12 @@ "entry": ["scripts/*.ts"], }, "packages/stack": { - // Source-mode NativeProcess resolves this standalone launcher by URL. - "entry": ["src/runtime/native-launcher.ts"], + "entry": [ + // Source-mode NativeProcess resolves this standalone launcher by URL. + "src/runtime/native-launcher.ts", + // scripts/generate-functions-bootstrap.ts bundles the Functions main service by path. + "src/functions/serve.main.ts", + ], // Used by the raw WebSocket integration fixture; Knip excludes that test-only import. "ignoreDependencies": ["@types/ws", "ws"], }, diff --git a/package.json b/package.json index 0dcaf3b62c..0640a4b9fd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "prepare": "effect-tsgo patch --no-typescript --oxlint && husky", "build": "pnpm exec turbo run build", - "generate": "pnpm exec turbo run @supabase/api#generate && pnpm exec turbo run @supabase/docs#generate", + "generate": "pnpm exec turbo run @supabase/api#generate @supabase/stack#generate && pnpm exec turbo run @supabase/docs#generate", "test:live": "pnpm exec turbo run supabase#test:live --concurrency=1 --", "record": "pnpm exec turbo run @supabase/cli-e2e#record --", "test:smoke": "pnpm exec turbo run supabase#test:smoke --", diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 049082e6ae..9bdf25fe02 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -725,7 +725,7 @@ A Functions runtime exit must reach `followStatus` so serve can report it. The Stack package remains the owner of identity semantics. It canonicalizes `projectRoot`, resolves the Git branch context (or ordinary-workspace fallback), and validates the stack name in [`Identity.ts`](./src/identity/Identity.ts); it also owns `deriveStackId` from that complete tuple. The CLI currently calls `resolveStackIdentity` through the internal [`identity` entrypoint](./src/identity/Identity.ts), then uses the result when matching `discover` records for status and related read operations. Resolving identity is read-only and does not create a stack. A follow-up recommendation is to expose an equivalent public, read-only `resolveIdentity` operation so the CLI need not import an internal entrypoint; this is a recommended public API, not an existing export. -When `stack start` includes Functions, the CLI uses the existing package export `@supabase/stack/internal/functions/serve-main`, whose source is [`serve.main.ts`](./src/functions/serve.main.ts), as the bootstrap entrypoint for esbuild bundling in [`stack-functions-bundler.ts`](../../apps/cli/src/command-internal/stack-functions-bundler.ts). The stack-backed `functions serve` command uses the same bootstrap when it creates a temporary Functions instance. Configured embedded templates may satisfy the same bootstrap input before bundling is needed. +The Functions recipe publishes a default Edge Runtime main service built from [`serve.main.ts`](./src/functions/serve.main.ts). [`generate-functions-bootstrap.ts`](./scripts/generate-functions-bootstrap.ts) bundles it, with its dependencies inlined for offline use, into the committed module [`serve-main-bundle.ts`](./src/functions/generated/serve-main-bundle.ts); `pnpm generate` refreshes it and a unit test fails when it drifts from the sources. A creation may override it with `bootstrap`; the default is not saved in the stack document. `stack start` and the stack-backed `functions serve` command use the default. The CLI owns the foreground `functions serve` session. It attaches to an existing composition member and leaves it available on exit. Supported explicit overrides replace its configuration for the session, then restore it on normal cleanup. If Functions is excluded, the CLI creates and later destroys one standalone instance without changing composition. The package needs no session or recovery API: ordinary create, start, restart, status, logs, and destroy suffice. Functions accepts custom environment values and a database URL; its recipe derives default keys, while the composer supplies the runtime database URL without a dependency edge. See the [command lifecycle](../../apps/cli/docs/stack-commands.md) for supported flags and cleanup limits. diff --git a/packages/stack/README.md b/packages/stack/README.md index 07aad604d9..792e5bf956 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -61,7 +61,7 @@ Creating a service records its definition. Configured public ports are bound dur Native public listeners bind to loopback. Docker and Podman public proxies bind all interfaces so services inside the container network can reach them; those listeners are reachable from the LAN according to the host firewall. -Functions configuration requires bootstrap source. Database versions belong in `config.version`; other recipes accept an optional top-level artifact `version`. +Functions use a package-provided, self-contained Edge Runtime main service unless the configuration supplies `bootstrap` source; only an explicit `bootstrap` is saved with the service definition. Database versions belong in `config.version`; other recipes accept an optional top-level artifact `version`. On Linux, native Functions project files must be outside `/tmp`: Edge Runtime uses a private filesystem at that path. Docker and Podman mount project files at a separate runtime path. diff --git a/packages/stack/package.json b/packages/stack/package.json index c3269fa466..92a5beee24 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -10,12 +10,12 @@ "./commands": "./src/Commands.ts", "./internal/dispatch": "./src/internal/dispatch.ts", "./internal/identity": "./src/identity/Identity.ts", - "./internal/functions/serve-main": "./src/functions/serve.main.ts", "./internal/postgres-artifact": "./src/internal/postgres-artifact.ts", "./internal/release": "./src/internal/release.ts", "./internal/service-catalog": "./src/internal/service-catalog.ts" }, "scripts": { + "generate": "bun run scripts/generate-functions-bootstrap.ts", "test": "pnpm run test:unit && pnpm run test:integration", "test:unit": "pnpm exec turbo run @supabase/stack#test:unit:run --", "test:unit:run": "bun --bun vitest run --project unit", diff --git a/packages/stack/scripts/generate-functions-bootstrap.ts b/packages/stack/scripts/generate-functions-bootstrap.ts new file mode 100644 index 0000000000..66a2d0fc4b --- /dev/null +++ b/packages/stack/scripts/generate-functions-bootstrap.ts @@ -0,0 +1,76 @@ +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { build, stop } from "esbuild"; +import { Data, Effect, FileSystem, Path } from "effect"; + +class FunctionsBootstrapBundleError extends Data.TaggedError("FunctionsBootstrapBundleError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const packageRoot = Effect.gen(function* () { + const path = yield* Path.Path; + return path.dirname(path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))); +}); + +/** Location of the committed default Functions bootstrap module. */ +export const generatedModulePath = Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(yield* packageRoot, "src", "functions", "generated", "serve-main-bundle.ts"); +}); + +const bundleServeMain = Effect.gen(function* () { + const path = yield* Path.Path; + const entrypoint = path.join(yield* packageRoot, "src", "functions", "serve.main.ts"); + const result = yield* Effect.tryPromise({ + try: () => + build({ + entryPoints: [entrypoint], + bundle: true, + format: "esm", + platform: "browser", + minify: true, + write: false, + legalComments: "none", + logLevel: "silent", + }), + catch: (cause) => + new FunctionsBootstrapBundleError({ message: "Unable to bundle functions bootstrap", cause }), + }); + const output = result.outputFiles[0]?.text; + if (output === undefined) + return yield* new FunctionsBootstrapBundleError({ + message: "esbuild produced no functions bootstrap output", + }); + return output; +}).pipe((program) => + Effect.uninterruptibleMask((restore) => + Effect.flatMap(Effect.exit(restore(program)), (result) => + Effect.tryPromise({ + try: () => stop(), + catch: (cause) => + new FunctionsBootstrapBundleError({ message: "Unable to stop esbuild", cause }), + }).pipe(Effect.andThen(result)), + ), + ), +); + +/** Renders the generated module that embeds the self-contained Edge Runtime main service. */ +export const renderFunctionsBootstrapModule = Effect.map( + bundleServeMain, + (bundle) => + "// Generated by packages/stack/scripts/generate-functions-bootstrap.ts; run `pnpm generate`.\n" + + "/** Bundled Edge Runtime main service used when a Functions creation has no bootstrap. */\n" + + `export const defaultFunctionsBootstrap = ${JSON.stringify(bundle)};\n`, +); + +if (import.meta.main) + NodeRuntime.runMain( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = yield* generatedModulePath; + const source = yield* renderFunctionsBootstrapModule; + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, source); + }).pipe(Effect.provide(NodeServices.layer)), + ); diff --git a/packages/stack/scripts/generate-functions-bootstrap.unit.test.ts b/packages/stack/scripts/generate-functions-bootstrap.unit.test.ts new file mode 100644 index 0000000000..e2830cfba5 --- /dev/null +++ b/packages/stack/scripts/generate-functions-bootstrap.unit.test.ts @@ -0,0 +1,24 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { + generatedModulePath, + renderFunctionsBootstrapModule, +} from "./generate-functions-bootstrap.ts"; + +describe("generated Functions bootstrap", () => { + it.effect( + "matches a fresh bundle of the main service sources", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const committed = yield* fs.readFileString(yield* generatedModulePath); + const rendered = yield* renderFunctionsBootstrapModule; + expect( + committed === rendered, + "src/functions/generated/serve-main-bundle.ts is stale; run `pnpm generate` in packages/stack", + ).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + 30_000, + ); +}); diff --git a/packages/stack/src/functions/generated/serve-main-bundle.ts b/packages/stack/src/functions/generated/serve-main-bundle.ts new file mode 100644 index 0000000000..543283d181 --- /dev/null +++ b/packages/stack/src/functions/generated/serve-main-bundle.ts @@ -0,0 +1,3 @@ +// Generated by packages/stack/scripts/generate-functions-bootstrap.ts; run `pnpm generate`. +/** Bundled Edge Runtime main service used when a Functions creation has no bootstrap. */ +export const defaultFunctionsBootstrap = "var l2=Object.defineProperty;var uo=(e,t)=>{for(var n in t)l2(e,n,{get:t[n],enumerable:!0})};var K=(e,t)=>{switch(t.length){case 0:return e;case 1:return t[0](e);case 2:return t[1](t[0](e));case 3:return t[2](t[1](t[0](e)));case 4:return t[3](t[2](t[1](t[0](e))));case 5:return t[4](t[3](t[2](t[1](t[0](e)))));case 6:return t[5](t[4](t[3](t[2](t[1](t[0](e))))));case 7:return t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))));case 8:return t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e))))))));case 9:return t[8](t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))))));default:{let n=e;for(let r=0,o=t.length;rt(n,...arguments)};switch(e){case 0:case 1:throw new RangeError(`Invalid arity ${e}`);case 2:return function(n,r){return arguments.length>=2?t(n,r):function(o){return t(o,n)}};case 3:return function(n,r,o){return arguments.length>=3?t(n,r,o):function(s){return t(s,n,r)}};default:return function(){if(arguments.length>=e)return t.apply(this,arguments);let n=arguments;return function(r){return t(r,...n)}}}};var _=e=>e;var Le=e=>()=>e,cn=Le(!0),Au=Le(!1),Ag=Le(null),fo=Le(void 0),Rr=fo;function Id(e,...t){return K(e,t)}function wd(e,t,n,r,o,s,i,a,c){switch(arguments.length){case 1:return e;case 2:return function(){return t(e.apply(this,arguments))};case 3:return function(){return n(t(e.apply(this,arguments)))};case 4:return function(){return r(n(t(e.apply(this,arguments))))};case 5:return function(){return o(r(n(t(e.apply(this,arguments)))))};case 6:return function(){return s(o(r(n(t(e.apply(this,arguments))))))};case 7:return function(){return i(s(o(r(n(t(e.apply(this,arguments)))))))};case 8:return function(){return a(i(s(o(r(n(t(e.apply(this,arguments))))))))};case 9:return function(){return c(a(i(s(o(r(n(t(e.apply(this,arguments)))))))))}}}function It(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),o}}function di(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),t.set(o,o),o}}var Cu=e=>{let t=new Set(Reflect.ownKeys(e));if(e.constructor===Object)return t;e instanceof Error&&t.delete(\"stack\");let n=Object.getPrototypeOf(e),r=n;for(;r!==null&&r!==Object.prototype;){let o=Reflect.ownKeys(r);for(let s=0;sku(e)&&t in e),$t=l(2,(e,t)=>M(e,\"_tag\")&&e._tag===t);function I0(e){return e instanceof Error}function $o(e){return M(e,Symbol.iterator)||$n(e)}var be=\"~effect/interfaces/Hash\",H=e=>{switch(typeof e){case\"number\":return On(e);case\"bigint\":return Ue(e.toString(10));case\"boolean\":return Ue(String(e));case\"symbol\":return Ue(String(e));case\"string\":return Ue(e);case\"undefined\":return Ue(\"undefined\");case\"function\":case\"object\":{if(e===null)return Ue(\"null\");if(e instanceof Date)return Number.isNaN(e.getTime())?Ue(\"Invalid Date\"):Ue(e.toISOString());if(e instanceof RegExp)return Ue(e.toString());{if(La.has(e))return Ad(e);if(Rg.has(e))return Rg.get(e);let t=g2(e,()=>p2(e)?e[be]():typeof e==\"function\"?Ad(e):e instanceof DataView?$a(new Uint8Array(e.buffer,e.byteOffset,e.byteLength)):Array.isArray(e)||ArrayBuffer.isView(e)?$a(e):e instanceof Map?m2(e):e instanceof Set?h2(e):Pg(e));return Rg.set(e,t),t}}default:throw new Error(`BUG: unhandled typeof ${typeof e} - please report an issue at https://github.com/Effect-TS/effect/issues`)}},Ad=e=>(kg.has(e)||kg.set(e,On(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))),kg.get(e)),it=l(2,(e,t)=>e*53^t),Ts=e=>e&3221225471|e>>>1&1073741824,p2=e=>M(e,be),On=e=>{if(e!==e)return Ue(\"NaN\");if(e===1/0)return Ue(\"Infinity\");if(e===-1/0)return Ue(\"-Infinity\");let t=e|0;for(t!==e&&(t^=e*4294967295);e>4294967295;)t^=e/=4294967295;return Ts(t)},Ue=e=>{let t=5381,n=e.length;for(;n;)t=t*33^e.charCodeAt(--n);return Ts(t)},Mg=(e,t)=>{let n=12289;for(let r of t)n^=it(H(r),H(e[r]));return Ts(n)},Pg=e=>Mg(e,Cu(e)),Fg=(e,t)=>n=>{let r=e;for(let o of n)r^=t(o);return Ts(r)},$a=Fg(6151,H),m2=Fg(Ue(\"Map\"),([e,t])=>it(H(e),H(t))),h2=Fg(Ue(\"Set\"),H),kg=new WeakMap,Rg=new WeakMap,_g=new WeakSet;function g2(e,t){if(_g.has(e))return Ue(\"[Circular]\");_g.add(e);let n=t();return _g.delete(e),n}var Se=\"~effect/interfaces/Equal\";function B(){return arguments.length===1?e=>pi(e,arguments[0]):pi(arguments[0],arguments[1])}function pi(e,t){if(e===t)return!0;if(e==null||t==null)return!1;let n=typeof e;return n!==typeof t?!1:n===\"number\"&&e!==e&&t!==t?!0:n!==\"object\"&&n!==\"function\"||La.has(e)||La.has(t)?!1:b2(e,t,y2)}function x2(e,t,n){let r=Ug.has(e),o=Ng.has(t);if(r&&o)return!0;if(r||o)return!1;Ug.add(e),Ng.add(t);let s=n();return Ug.delete(e),Ng.delete(t),s}var Ug=new WeakSet,Ng=new WeakSet;function y2(e,t){if(H(e)!==H(t))return!1;if(e instanceof Date){if(!(t instanceof Date))return!1;let s=e.getTime(),i=t.getTime();return s===i||Number.isNaN(s)&&Number.isNaN(i)}else if(e instanceof RegExp)return t instanceof RegExp?e.toString()===t.toString():!1;let n=T0(e),r=T0(t);if(n!==r)return!1;let o=n&&r;return typeof e==\"function\"&&!o?!1:x2(e,t,()=>{if(o)return e[Se](t);if(Array.isArray(e))return!Array.isArray(t)||e.length!==t.length?!1:S2(e,t);if(ArrayBuffer.isView(e)){let s=e instanceof DataView;if(!ArrayBuffer.isView(t)||e.byteLength!==t.byteLength||s!==t instanceof DataView)return!1;if(s){let i=t;return w0(new Uint8Array(e.buffer,e.byteOffset,e.byteLength),new Uint8Array(i.buffer,i.byteOffset,i.byteLength))}return w0(e,t)}else{if(e instanceof Map)return!(t instanceof Map)||e.size!==t.size?!1:I2(e,t);if(e instanceof Set)return!(t instanceof Set)||e.size!==t.size?!1:w2(e,t)}return E2(e,t)})}function b2(e,t,n){let r=Cd.get(e);if(!r)r=new WeakMap,Cd.set(e,r);else if(r.has(t))return r.get(t);let o=n(e,t);r.set(t,o);let s=Cd.get(t);return s||(s=new WeakMap,Cd.set(t,s)),s.set(e,o),o}var Cd=new WeakMap;function S2(e,t){for(let n=0;nM(e,Se),Dg=()=>B;var Lg=e=>(La.add(e),e);function or(e,t,n){return{combine:e,initialValue:t,combineAll:n??(r=>{let o=t;for(let s of r)o=e(o,s);return o})}}var Rt=e=>(t,n)=>t===n||e(t,n),T2=(e,t)=>e===t,v0=()=>T2;function A0(e){return Rt((t,n)=>{if(t.length!==n.length)return!1;for(let r=0;re.length>0;var Rd=e=>l(3,(t,n,r)=>e(t,o=>({...o,[n]:r(o)}))),_d=e=>l(2,(t,n)=>e(t,r=>({[n]:r}))),Md=(e,t)=>l(3,(n,r,o)=>t(n,s=>e(o(s),i=>({...s,[r]:i}))));function F(e,t,n){t===\"__proto__\"?Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0}):e[t]=n}function Pd(e,t){for(let n of Reflect.ownKeys(t))Object.prototype.propertyIsEnumerable.call(t,n)&&F(e,n,t[n])}var ye={};uo(ye,{Do:()=>bL,all:()=>aL,andThen:()=>tL,as:()=>H0,asVoid:()=>X2,bind:()=>yL,bindTo:()=>gL,composeK:()=>sL,contains:()=>mL,containsWith:()=>Z0,exists:()=>hL,filter:()=>Ai,filterMap:()=>fL,firstSomeOf:()=>Y2,flatMap:()=>Ti,flatMapNullishOr:()=>nL,flatten:()=>vi,fromIterable:()=>H2,fromNullOr:()=>Yg,fromNullishOr:()=>Bu,fromUndefinedOr:()=>Zg,gen:()=>SL,getFailure:()=>K2,getOrElse:()=>ju,getOrNull:()=>Wd,getOrThrow:()=>Xg,getOrThrowWith:()=>Qg,getOrUndefined:()=>ks,getSuccess:()=>J2,isNone:()=>ae,isOption:()=>Qa,isSome:()=>_e,let:()=>xL,lift2:()=>dL,liftNullishOr:()=>Q2,liftPredicate:()=>pL,liftThrowable:()=>Rs,makeCombinerFailFast:()=>Y0,makeEquivalence:()=>ex,makeOrder:()=>lL,makeReducer:()=>EL,makeReducerFailFast:()=>IL,map:()=>Bt,match:()=>fr,none:()=>R,orElse:()=>G2,orElseResult:()=>Z2,orElseSome:()=>V2,partitionMap:()=>uL,product:()=>K0,productMany:()=>iL,reduceCompact:()=>cL,some:()=>k,tap:()=>J0,toArray:()=>V0,toRefinement:()=>W2,void:()=>eL,zipLeft:()=>oL,zipRight:()=>rL,zipWith:()=>G0});function Fd(e){return{combine:e}}var Ru=Symbol.for(\"~effect/Redactable\"),A2=e=>M(e,Ru);function Ba(e){return A2(e)?Bg(e):e}function Bg(e){return e[Ru](globalThis[qa]?.context??C2)}var qa=\"~effect/Fiber/currentFiber\",O0=new Map,C2={\"~effect/Context\":{},base:O0,depth:0,mapUnsafe:O0,pipe(){return K(this,arguments)}};function N(e,t){let n=t?.space??0,r=new WeakSet,o=n?typeof n==\"number\"?\" \".repeat(n):n:\"\",s=u=>o.repeat(u),i=(u,f)=>{let d=u?.constructor;return d&&d!==Object.prototype.constructor&&d.name?`${d.name}(${f})`:f},a=u=>{try{return Reflect.ownKeys(u)}catch{return[\"[ownKeys threw]\"]}};function c(u,f=0){if(typeof u==\"string\")return JSON.stringify(u);if(typeof u==\"number\"||u==null||typeof u==\"boolean\"||typeof u==\"symbol\")return String(u);if(typeof u==\"bigint\")return String(u)+\"n\";if(typeof u==\"object\"||typeof u==\"function\"){if(r.has(u))return k2;r.add(u);let d;if(Ru in u)d=c(Bg(u),f);else if(Array.isArray(u))d=!o||u.length<=1?`[${u.map(p=>c(p,f)).join(\",\")}]`:`[\n${s(f+1)}${u.map(p=>c(p,f+1)).join(`,\n`+s(f+1))}\n${s(f)}]`;else if(u instanceof Date)d=qg(u);else if(!t?.ignoreToString&&M(u,\"toString\")&&typeof u.toString==\"function\"&&u.toString!==Object.prototype.toString&&u.toString!==Array.prototype.toString){let p=R2(u);d=u instanceof Error&&u.cause?`${p} (cause: ${c(u.cause,f)})`:p}else if(Symbol.iterator in u)d=`${u.constructor.name}(${c(Array.from(u),f)})`;else{let p=a(u);if(!o||p.length<=1){let m=`{${p.map(g=>`${Bo(g)}:${c(u[g],f)}`).join(\",\")}}`;d=i(u,m)}else{let m=`{\n${p.map(g=>`${s(f+1)}${Bo(g)}: ${c(u[g],f+1)}`).join(`,\n`)}\n${s(f)}}`;d=i(u,m)}}return r.delete(u),d}return String(u)}return c(e,0)}var k2=\"[Circular]\";function Bo(e){return typeof e==\"string\"?JSON.stringify(e):String(e)}function Ud(e){return e.map(t=>`[${Bo(t)}]`).join(\"\")}function qg(e){try{return e.toISOString()}catch{return\"Invalid Date\"}}function R2(e){try{let t=e.toString();return typeof t==\"string\"?t:String(t)}catch{return\"[toString threw]\"}}function mi(e,t){let n=[];return JSON.stringify(e,function(r,o){let s=Object.getOwnPropertyDescriptor(this,r)?.value,i=M(s,Ru)?Ba(s):Ba(o);if(typeof i==\"bigint\")return N(i);if(typeof i!=\"object\"||i===null)return i;for(;n.length>0&&n[n.length-1]!==this;)n.pop();if(!n.includes(i))return n.push(i),i},t?.space)??\"null\"}var Qe=Symbol.for(\"nodejs.util.inspect.custom\"),ht=e=>{try{return e=Ba(e),M(e,\"toJSON\")&&un(e.toJSON)&&e.toJSON.length===0?e.toJSON():Array.isArray(e)?e.map(ht):e}catch{return\"[toJSON threw]\"}},R0=(e,t=2)=>{if(typeof e==\"string\")return e;try{return typeof e==\"object\"?mi(e,{space:t}):N(e,{space:t})}catch{return String(e)}},FQ={toJSON(){return ht(this)},[Qe](){return this.toJSON()},toString(){return N(this.toJSON())}},k0=class{[Qe](){return this.toJSON()}toString(){return N(this.toJSON())}};var vs=class e{called=!1;self;constructor(t){this.self=t}next(t){return this.called?{value:t,done:!0}:(this.called=!0,{value:this.self,done:!1})}[Symbol.iterator](){return new e(this.self)}},M2=()=>{let e=\"~effect/Utils/internal\",t={[e]:o=>o()},n={[e]:o=>o()};return t[e](()=>new Error().stack)?.includes(e)===!0?t[e]:n[e]},we=M2();var po=\"~effect/Effect\",hi=\"~effect/Exit\",F2={_A:_,_E:_,_R:_},P0=`${po}/identifier`,Z=`${po}/args`,at=`${po}/evaluate`,fn=`${po}/successCont`,sr=`${po}/failureCont`,As=`${po}/ensureCont`,yi=Symbol.for(\"effect/Effect/Yield\"),ln={pipe(){return K(this,arguments)},toJSON(){return{...this}},toString(){return N(this.toJSON(),{ignoreToString:!0,space:2})},[Qe](){return this.toJSON()}},F0={[be](){return Mg(this,Object.keys(this))},[Se](e){let t=Object.keys(this),n=Object.keys(e);if(t.length!==n.length)return!1;for(let r=0;rM(e,po),zg=e=>M(e,hi),za=\"~effect/Cause\",Wa=\"~effect/Cause/Reason\",Ja=e=>M(e,za),U0=e=>M(e,Wa),lo=class{[za];reasons;constructor(t){this[za]=za,this.reasons=t}pipe(){return K(this,arguments)}toJSON(){return{_id:\"Cause\",failures:this.reasons.map(t=>t.toJSON())}}toString(){return`Cause(${N(this.reasons)})`}[Qe](){return this.toJSON()}[Se](t){return Ja(t)&&this.reasons.length===t.reasons.length&&this.reasons.every((n,r)=>B(n,t.reasons[r]))}[be](){return $a(this.reasons)}},M0=new WeakMap,Ha=class{[Wa];annotations;_tag;constructor(t,n,r){if(this[Wa]=Wa,this._tag=t,n!==Pu&&typeof r==\"object\"&&r!==null&&n.size>0){let o=M0.get(r);o&&(n=new Map([...o,...n])),M0.set(r,n)}this.annotations=n}annotate(t,n){if(t.mapUnsafe.size===0)return this;let r=new Map(this.annotations);t.mapUnsafe.forEach((s,i)=>{n?.overwrite!==!0&&r.has(i)||r.set(i,s)});let o=Object.assign(Object.create(Object.getPrototypeOf(this)),this);return o.annotations=r,o}pipe(){return K(this,arguments)}toString(){return N(this)}[Qe](){return this.toString()}},Pu=new Map,gi=class extends Ha{error;constructor(t,n=Pu){super(\"Fail\",n,t),this.error=t}toString(){return`Fail(${N(this.error)})`}toJSON(){return{_tag:\"Fail\",error:this.error}}[Se](t){return Cs(t)&&B(this.error,t.error)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.error))(H(this.annotations)))}},qo=e=>new lo(e),Nd=new lo([]),Ka=e=>new lo([new gi(e)]),_u=class extends Ha{defect;constructor(t,n=Pu){super(\"Die\",n,t),this.defect=t}toString(){return`Die(${N(this.defect)})`}toJSON(){return{_tag:\"Die\",defect:this.defect}}[Se](t){return bi(t)&&B(this.defect,t.defect)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.defect))(H(this.annotations)))}},Wg=e=>new lo([new _u(e)]),Ga=l(e=>Ja(e[0]),(e,t,n)=>t.mapUnsafe.size===0?e:new lo(e.reasons.map(r=>r.annotate(t,n)))),Cs=e=>e._tag===\"Fail\",bi=e=>e._tag===\"Die\",Si=e=>e._tag===\"Interrupt\";function N2(e){return ar(\"Effect.evaluate: Not implemented\")}var zo=e=>({...U2,[P0]:e.op,[at]:e[at]??N2,[fn]:e[fn],[sr]:e[sr],[As]:e[As]}),ir=e=>{let t=zo(e);return function(){let n=Object.create(t);return n[Z]=e.single===!1?arguments:arguments[0],n}},N0=e=>{let t={[hi]:hi,_tag:e.op,get[e.prop](){return this[Z]},...zo(e),toString(){return`${e.op}(${N(this[Z])})`},toJSON(){return{_id:\"Exit\",_tag:e.op,[e.prop]:this[Z]}},[Se](n){return zg(n)&&n._tag===this._tag&&B(this[Z],n[Z])},[be](){return it(Ue(e.op),H(this[Z]))}};return function(n){let r=Object.create(t);return r[Z]=n,r}},Oe=N0({op:\"Success\",prop:\"value\",[at](e){let t=e.getCont(fn);return t?t[fn](this[Z],e,this):e.yieldWith(this)}}),Dd={key:\"effect/Cause/StackTrace\"},Hg={key:\"effect/Cause/InterruptorStackTrace\"},Mt=N0({op:\"Failure\",prop:\"cause\",[at](e){let t=this[Z],n=!1;e.currentStackFrame&&(t=Ga(t,{mapUnsafe:new Map([[Dd.key,e.currentStackFrame]])}),n=!0);let r=e.getCont(sr);for(;e.interruptible&&e._interruptedCause&&r;)r=e.getCont(sr);return r?r[sr](t,e,n?void 0:this):e.yieldWith(n?Mt(t):this)}}),jn=e=>Mt(Ka(e)),ar=e=>Mt(Wg(e)),Y=ir({op:\"WithFiber\",[at](e){return this[Z](e)}}),D2=(function(){class e extends globalThis.Error{}let t=zo({op:\"YieldableError\",[at](){return jn(this)}});return delete t.toString,Object.assign(e.prototype,t),e})(),Fu=(function(){let e=Symbol.for(\"effect/Data/Error/plainArgs\");return class extends D2{constructor(n){super(n?.message,n?.cause?{cause:n.cause}:void 0),n&&(Pd(this,n),Object.defineProperty(this,e,{value:n,enumerable:!1}))}toJSON(){return{...this[e],...this}}}})(),Wo=e=>{class t extends Fu{_tag=e}return t.prototype.name=e,t},Mu=\"~effect/Cause/NoSuchElementError\",Ld=e=>M(e,Mu),_r=class extends Wo(\"NoSuchElementError\"){[Mu]=Mu;constructor(t){super({message:t})}},xi=\"~effect/Cause/Done\",Uu=e=>M(e,xi),D0={[xi]:xi,_tag:\"Done\",value:void 0},Ei=e=>e===void 0?D0:{[xi]:xi,_tag:\"Done\",value:e},L2=jn(D0),L0=e=>e===void 0?L2:jn(Ei(e));var $0=\"~effect/data/Option\",j0={[$0]:{_A:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},$2=Object.defineProperty(Object.assign(Object.create(j0),{_tag:\"Some\",_op:\"Some\",[Se](e){return $d(e)&&Jg(e)&&B(this.value,e.value)},[be](){return it(H(this._tag))(H(this.value))},toString(){return`some(${N(this.value)})`},toJSON(){return{_id:\"Option\",_tag:this._tag,value:ht(this.value)}}}),\"valueOrUndefined\",{get(){return this.value}}),j2=H(\"None\"),B2=Object.assign(Object.create(j0),{_tag:\"None\",_op:\"None\",valueOrUndefined:void 0,[Se](e){return $d(e)&&jd(e)},[be](){return j2},toString(){return\"none()\"},toJSON(){return{_id:\"Option\",_tag:this._tag}}}),$d=e=>M(e,$0),jd=e=>e._tag===\"None\",Jg=e=>e._tag===\"Some\",Nu=Object.create(B2),Du=e=>{let t=Object.create($2);return t.value=e,t};var q0=\"~effect/data/Result\",z0={[q0]:{_A:e=>e,_E:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},q2=Object.assign(Object.create(z0),{_tag:\"Success\",_op:\"Success\",[Se](e){return Bd(e)&&Za(e)&&B(this.success,e.success)},[be](){return it(H(this._tag))(H(this.success))},toString(){return`success(${N(this.success)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,value:ht(this.success)}}}),z2=Object.assign(Object.create(z0),{_tag:\"Failure\",_op:\"Failure\",[Se](e){return Bd(e)&&Va(e)&&B(this.failure,e.failure)},[be](){return it(H(this._tag))(H(this.failure))},toString(){return`failure(${N(this.failure)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,failure:ht(this.failure)}}}),Bd=e=>M(e,q0),Va=e=>e._tag===\"Failure\",Za=e=>e._tag===\"Success\",qd=e=>{let t=Object.create(z2);return t.failure=e,t},zd=e=>{let t=Object.create(q2);return t.success=e,t},Kg=e=>Za(e)?Nu:Du(e.failure),Gg=e=>Va(e)?Nu:Du(e.success);function Ho(e){return(t,n)=>t===n?0:e(t,n)}var Bn=Ho((e,t)=>globalThis.Number.isNaN(e)&&globalThis.Number.isNaN(t)?0:globalThis.Number.isNaN(e)?-1:globalThis.Number.isNaN(t)?1:eeHo((n,r)=>e(t(n),t(r)))),Ii=Vg(Bn,e=>e.getTime());var Ya=e=>l(2,(t,n)=>e(t,n)===-1),Os=e=>l(2,(t,n)=>e(t,n)===1),Lu=e=>l(2,(t,n)=>e(t,n)!==1),$u=e=>l(2,(t,n)=>e(t,n)!==-1);var R=()=>Nu,k=Du,Qa=$d,ae=jd,_e=Jg,fr=l(2,(e,{onNone:t,onSome:n})=>ae(e)?t():n(e.value)),W2=e=>t=>_e(e(t)),H2=e=>{for(let t of e)return k(t);return R()},J2=Gg,K2=Kg,ju=l(2,(e,t)=>ae(e)?t():e.value),G2=l(2,(e,t)=>ae(e)?t():e),V2=l(2,(e,t)=>ae(e)?k(t()):e),Z2=l(2,(e,t)=>ae(e)?Bt(t(),zd):Bt(e,qd)),Y2=e=>{let t=R();for(t of e)if(_e(t))return t;return t},Bu=e=>e==null?R():k(e),Zg=e=>e===void 0?R():k(e),Yg=e=>e===null?R():k(e),Q2=e=>(...t)=>Bu(e(...t)),Wd=ju(Ag),ks=ju(fo),Rs=e=>(...t)=>{try{return k(e(...t))}catch{return R()}},Qg=l(2,(e,t)=>{if(_e(e))return e.value;throw t()}),Xg=Qg(()=>new Error(\"getOrThrow called on a None\")),Bt=l(2,(e,t)=>ae(e)?R():k(t(e.value))),H0=l(2,(e,t)=>Bt(e,()=>t)),X2=H0(void 0),eL=k(void 0);var Ti=l(2,(e,t)=>ae(e)?R():t(e.value)),tL=l(2,(e,t)=>Ti(e,n=>{let r=un(t)?t(n):t;return Qa(r)?r:k(r)})),nL=l(2,(e,t)=>ae(e)?R():Bu(t(e.value))),vi=Ti(_),rL=l(2,(e,t)=>Ti(e,()=>t)),oL=l(2,(e,t)=>J0(e,()=>t)),sL=l(2,(e,t)=>n=>Ti(e(n),t)),J0=l(2,(e,t)=>Ti(e,n=>Bt(t(n),()=>n))),K0=(e,t)=>_e(e)&&_e(t)?k([e.value,t.value]):R(),iL=(e,t)=>{if(ae(e))return R();let n=[e.value];for(let r of t){if(ae(r))return R();n.push(r.value)}return k(n)},aL=e=>{if(Symbol.iterator in e){let n=[];for(let r of e){if(ae(r))return R();n.push(r.value)}return k(n)}let t={};for(let n of Object.keys(e)){let r=e[n];if(ae(r))return R();F(t,n,r.value)}return k(t)},G0=l(3,(e,t,n)=>Bt(K0(e,t),([r,o])=>n(r,o))),cL=l(3,(e,t,n)=>{let r=t;for(let o of e)_e(o)&&(r=n(r,o.value));return r}),V0=e=>ae(e)?[]:[e.value],uL=l(2,(e,t)=>{if(ae(e))return[R(),R()];let n=t(e.value);return Va(n)?[k(n.failure),R()]:[R(),k(n.success)]}),fL=l(2,(e,t)=>{if(ae(e))return R();let n=t(e.value);return Za(n)?k(n.success):R()}),Ai=l(2,(e,t)=>ae(e)?R():t(e.value)?k(e.value):R()),ex=e=>Rt((t,n)=>ae(t)?ae(n):ae(n)?!1:e(t.value,n.value)),lL=e=>Ho((t,n)=>_e(t)?_e(n)?e(t.value,n.value):1:-1),dL=e=>l(2,(t,n)=>G0(t,n,e)),pL=l(2,(e,t)=>t(e)?k(e):R()),Z0=e=>l(2,(t,n)=>ae(t)?!1:e(t.value,n)),mL=Z0(Dg()),hL=l(2,(e,t)=>ae(e)?!1:t(e.value)),gL=_d(Bt),xL=Rd(Bt);var yL=Md(Bt,Ti),bL=k({}),SL=(...e)=>{let n=(e.length===1?e[0]:e[1].bind(e[0]))(),r=n.next();for(;!r.done;){let o=r.value;if(ae(o))return o;r=n.next(o.value)}return k(r.value)};function EL(e){return or((t,n)=>ae(t)?n:ae(n)?t:k(e.combine(t.value,n.value)),R())}function Y0(e){return Fd((t,n)=>ae(t)||ae(n)?R():k(e.combine(t.value,n.value)))}function IL(e){let t=Y0(e).combine,n=k(e.initialValue);return or(t,n,r=>{let o=n;for(let s of r)if(o=t(o,s),ae(o))return o;return o})}var se=zd,re=qd;var Q0=e=>{if(un(e))try{return se(e())}catch(t){return re(t)}else try{return se(e.try())}catch(t){return re(e.catch(t))}};var X0=Bd,ie=Va,Tt=Za;var eT=(e,t)=>Rt((n,r)=>ie(n)?ie(r)&&t(n.failure,r.failure):Tt(r)&&e(n.success,r.success));var Hd=l(2,(e,t)=>ie(e)?re(t(e.failure)):e),Ci=l(2,(e,t)=>Tt(e)?se(t(e.success)):e),lr=l(2,(e,{onFailure:t,onSuccess:n})=>ie(e)?t(e.failure):n(e.success));var qu=l(2,(e,t)=>ie(e)?t(e.failure):e.success);var Jd=l(2,(e,t)=>ie(e)?re(e.failure):t(e.success));var wL=(e,t)=>{let n=t?.length!==void 0?Math.max(1,Math.floor(t.length)):1/0;return{[Symbol.iterator](){let r=0;return{next(){return rvL(wL(()=>e,{length:t}))),tT=e=>TL(e,1/0);var nT=e=>{let n=e[Symbol.iterator]().next();if(n.done)throw new Error(\"headUnsafe: empty iterable\");return n.value};var vL=e=>({[Symbol.iterator](){let t=e[Symbol.iterator](),n;function r(){for(;;){if(n===void 0){let s=t.next();if(s.done)return s;n=s.value[Symbol.iterator]()}let o=n.next();if(!o.done)return o;n=void 0}}return{next:r}}});var rT=l(2,(e,t)=>({[Symbol.iterator](){let n=e[Symbol.iterator](),r=0;return{next(){let o=n.next();for(;!o.done;){if(t(o.value,r++))return{done:!1,value:o.value};o=n.next()}return{done:!0,value:void 0}}}}}));var zu=l(2,(e,t)=>{let n={...e};for(let r of AL(e))F(n,r,t(e[r],r));return n});var AL=e=>Object.keys(e);var Wu=globalThis.Array;var oT=l(2,(e,t)=>{let n=Math.max(1,Math.floor(e)),r=new Wu(n);for(let o=0;oe<=t?oT(t-e+1,n=>e+n):[e];var Be=e=>Wu.isArray(e)?e:Wu.from(e),sT=e=>Wu.isArray(e)?e:[e];var rx=l(2,(e,t)=>[...e,t]),ox=l(2,(e,t)=>Be(e).concat(Be(t)));var iT=Wu.isArray;var qt=$g,Me=$g;function CL(e,t){return!Number.isFinite(e)||e<0||e>=t.length}var aT=l(2,(e,t)=>{let n=Math.floor(t);if(CL(n,e))throw new Error(`Index out of bounds: ${n}`);return e[n]});var Gd=e=>e[e.length-1];var cT=l(2,(e,t)=>{let n=0,r=[];for(let o of e){if(!t(o,n))break;r.push(o),n++}return r});var OL=(e,t)=>{let n=H(t),r=e.get(n);if(r===void 0)return e.set(n,[t]),!0;for(let o of r)if(B(o,t))return!1;return r.push(t),!0};var sx=l(2,(e,t)=>{let n=Be(e),r=Be(t);return Me(n)?Me(r)?Vd(ox(n,r)):n:r});var gt=()=>[],Ee=e=>[e],Mr=l(2,(e,t)=>e.map(t));var ix=e=>{let t=[];for(let n of e)_e(n)&&t.push(n.value);return t};var ax=l(2,(e,t)=>{let n=Be(e),r=[];for(let o=0;o{let n=[],r=[],o=0;for(let s of e){let i=t(s,o++);Tt(i)?r.push(i.success):n.push(i.failure)}return[n,r]});var Vd=e=>{let t=Be(e);if(t.length<2)return[...t];let n=new Map,r=[];for(let o of t)OL(n,o)&&r.push(o);return r};var kL=or((e,t)=>e.concat(t),[]);function uT(){return kL}var fT=/^[+-]?\\d+$/,ux=\"~effect/BigDecimal\",FL={[ux]:ux,[be](){let e=fx(this);return it(H(e.value),On(e.scale))},[Se](e){return Yd(e)&&jL(this,e)},toString(){return`BigDecimal(${mo(this)})`},toJSON(){return{_id:\"BigDecimal\",value:String(this.value),scale:this.scale}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},Yd=e=>M(e,ux),Rn=(e,t)=>{let n=Object.create(FL);return n.value=e,n.scale=t,n},dT=(e,t)=>{if(e!==qn&&e%Zd===qn)throw new RangeError(\"Value must be normalized\");let n=Rn(e,t);return n.normalized=n,n},qn=BigInt(0),UL=BigInt(1),NL=BigInt(-1);var Zd=BigInt(10),pT=dT(qn,0);var fx=e=>{if(e.normalized===void 0)if(e.value===qn)e.normalized=pT;else{let t=`${e.value}`,n=0;for(let s=t.length-1;s>=0&&t[s]===\"0\";s--)n++;n===0&&(e.normalized=e);let r=BigInt(t.substring(0,t.length-n)),o=e.scale-n;e.normalized=dT(r,o)}return e.normalized},_s=l(2,(e,t)=>t>e.scale?Rn(e.value*Zd**BigInt(t-e.scale),t):tt.value===qn?e:e.value===qn?t:e.scale>t.scale?Rn(_s(t,e.scale).value+e.value,e.scale):e.scale{let n=Bn(lT(e),lT(t));return n!==0?n:e.scale>t.scale?ur(e.value,_s(t,e.scale).value):e.scalee.value===qn?0:e.valuee.valuee.scale>t.scale?_s(t,e.scale).value===e.value:e.scalelx(e,t));var hT=e=>{if(e===\"\")return k(pT);let t,n,r=e.search(/[eE]/);if(r!==-1){let c=e.slice(r+1);if(t=e.slice(0,r),n=Number(c),t===\"\"||!Number.isSafeInteger(n)||!fT.test(c))return R()}else t=e,n=0;let o,s,i=t.search(/\\./);if(i!==-1){let c=t.slice(0,i),u=t.slice(i+1);o=`${c}${u}`,s=u.length}else o=t,s=0;if(!fT.test(o))return R();let a=s-n;return Number.isSafeInteger(a)?k(Rn(BigInt(o),a)):R()};var mo=e=>{let t=fx(e);if(Math.abs(t.scale)>=16)return BL(t);let n=t.value=r.length)o=\"0\",s=\"0\".repeat(t.scale-r.length)+r;else{let a=r.length-t.scale;if(a>r.length){let c=a-r.length;o=`${r}${\"0\".repeat(c)}`,s=\"\"}else s=r.slice(a),o=r.slice(0,a)}let i=s===\"\"?o:`${o}.${s}`;return n?`-${i}`:i},BL=e=>{if(qL(e))return\"0e+0\";let t=fx(e),n=`${$L(t).value}`,r=n.slice(0,1),o=n.slice(1),s=`${gT(t)?\"-\":\"\"}${r}`;o!==\"\"&&(s+=`.${o}`);let i=o.length-t.scale;return`${s}e${i>=0?\"+\":\"\"}${i}`};var qL=e=>e.value===qn,gT=e=>e.valuee.value>qn,dx=e=>Yd(e[0]);var xT=l(dx,(e,t=0)=>e.scale<=t?e:Rn(e.value/Zd**BigInt(e.scale-t),t)),px=l(dx,(e,t=0)=>{let n=xT(e,t);return zL(e)&&DL(n,e)?mT(n,Rn(UL,t)):n});var mx=l(dx,(e,t=0)=>{let n=xT(e,t);return gT(e)&&LL(n,e)?mT(n,Rn(NL,t)):n});var l9=globalThis.Boolean;var yT=or((e,t)=>e||t,!1);var Wt={};uo(Wt,{AsyncFiberError:()=>Lj,AsyncFiberErrorTypeId:()=>Nj,Done:()=>ab,DoneTypeId:()=>Oj,ExceededCapacityError:()=>cb,ExceededCapacityErrorTypeId:()=>Uj,IllegalArgumentError:()=>Tf,IllegalArgumentErrorTypeId:()=>Mj,InterruptorStackTrace:()=>Qy,NoSuchElementError:()=>Cj,NoSuchElementErrorTypeId:()=>Aj,ReasonTypeId:()=>uj,StackTrace:()=>Yy,TimeoutError:()=>_j,TimeoutErrorTypeId:()=>kj,TypeId:()=>cj,UnknownError:()=>Bj,UnknownErrorTypeId:()=>$j,annotate:()=>qj,annotations:()=>Wj,combine:()=>mj,die:()=>nb,done:()=>G,empty:()=>tb,fail:()=>dj,filterInterruptors:()=>wj,findDefect:()=>bj,findDie:()=>yj,findError:()=>sb,findErrorOption:()=>gj,findFail:()=>hj,findInterrupt:()=>Ej,fromReasons:()=>ji,hasDies:()=>xj,hasFails:()=>ob,hasInterrupts:()=>Sj,hasInterruptsOnly:()=>rb,interrupt:()=>pj,interruptors:()=>Ij,isAsyncFiberError:()=>Dj,isCause:()=>Xy,isDieReason:()=>fj,isDone:()=>fc,isExceededCapacityError:()=>Fj,isFailReason:()=>Sf,isIllegalArgumentError:()=>Pj,isInterruptReason:()=>lj,isNoSuchElementError:()=>vj,isReason:()=>eb,isTimeoutError:()=>Rj,isUnknownError:()=>jj,makeDieReason:()=>If,makeFailReason:()=>Ef,makeInterruptReason:()=>wf,map:()=>ns,pretty:()=>ib,prettyErrors:()=>Tj,reasonAnnotations:()=>zj,squash:()=>zp});var Qd=e=>zo({op:e.label,[at]:e.evaluate});var ST=\"~effect/Context/Service\",Vt=function(){function e(){}let t=e;Object.setPrototypeOf(t,WL);let n=(r,o)=>(t.key=r,o?.defaultValue&&(t[yx]=yx,t.defaultValue=o.defaultValue),o?.make&&(t.make=o.make),o?.fiberCached&&ET.add(r),t);return arguments.length>0?n(arguments[0],arguments[1]):n},WL={[ST]:ST,...Qd({label:\"Service\",evaluate(e){return Oe(Xe(e.context,this))}}),toJSON(){return{_id:\"Service\",key:this.key}},of(e){return e},context(e){return Oi(this,e)},use(e){return Y(t=>e(Xe(t.context,this)))},useSync(e){return Y(t=>Oe(e(Xe(t.context,this))))}},ET=new Set,yx=\"~effect/Context/Reference\",IT=\"~effect/Context\",HL=8,JL=8,bx=(e,t,n,r)=>{let o=Object.create(GL);return o.cacheRoot=e??o,o.base=t,o.overlay=n,o.depth=r,o._flat=void 0,o.baseHits=0,o},wT=(e,t)=>{t&&(wT(e,t.parent),e.set(t.key,t.value))},TT=e=>{if(e._flat)return e._flat;if(!e.overlay)return e._flat=e.base;let t=new Map(e.base);return wT(t,e.overlay),e._flat=t},KL=(e,t)=>{let n=new Map(e.mapUnsafe);return t(n),ho(n)},Xd=Symbol(),Sx=(e,t)=>{let n=e;for(let o=n.overlay;o;o=o.parent)if(o.key===t)return o.value;let r=n.base.get(t);return r===void 0&&!n.base.has(t)?Xd:(n.overlay&&++n.baseHits>=JL&&(n.base=TT(n),n.overlay=void 0,n.depth=0),r)},ho=e=>bx(void 0,e,void 0,0),GL={get mapUnsafe(){return TT(this)},...ln,[IT]:{_Services:e=>e},toJSON(){return{_id:\"Context\",services:Array.from(this.mapUnsafe).map(([e,t])=>({key:e,value:t}))}},[Se](e){if(!Hu(e))return!1;let t=this.mapUnsafe,n=e.mapUnsafe;if(t.size!==n.size)return!1;for(let[r,o]of t)if(!n.has(r)||!B(o,n.get(r)))return!1;return!0},[be](){return On(this.mapUnsafe.size)}},vT=(e,t)=>e.cacheRoot===t.cacheRoot,Hu=e=>M(e,IT);var AT=e=>!!e[yx],pn=()=>VL,VL=ho(new Map),Oi=(e,t)=>ho(new Map([[e.key,t]])),Pt=l(3,(e,t,n)=>ZL(e,t.key,n)),ZL=(e,t,n)=>{let r=e,o=ET.has(t)?void 0:r.cacheRoot;if(r.depth>=HL){let s=new Map(r.mapUnsafe);return s.set(t,n),bx(o,s,void 0,0)}return bx(o,r.base,{key:t,value:n,parent:r.overlay},r.depth+1)};var ep=l(2,(e,t)=>Ju(e,t.key)),Ju=(e,t)=>{let n=Sx(e,t);return n===Xd?void 0:n},Pr=l(2,(e,t)=>{let n=Sx(e,t.key);if(n===Xd){if(AT(t))return CT(t);throw YL(t)}return n}),Xe=Pr,xx=\"~effect/Context/defaultValue\",CT=e=>xx in e?e[xx]:e[xx]=e.defaultValue(),YL=e=>{let t=new Error(`Service not found${e.key?`: ${String(e.key)}`:\"\"}`);if(t.stack){let n=t.stack.split(`\n`);n.splice(1,3),t.stack=n.join(`\n`)}return t},Ex=l(2,(e,t)=>{let n=Sx(e,t.key);return n!==Xd?k(n):AT(t)?k(CT(t)):R()}),tp=l(2,(e,t)=>e.mapUnsafe.size===0?t:t.mapUnsafe.size===0?e:KL(e,n=>t.mapUnsafe.forEach((r,o)=>n.set(o,r)))),OT=(...e)=>{let t=new Map;for(let n=0;n{t.set(o,r)});return ho(t)};var Ae=Vt;var np=\"~effect/time/Duration\",wx=BigInt(0),MT=BigInt(1),QL=BigInt(2),XL=BigInt(10);var e$=BigInt(1e3);var rp=e=>BigInt(e<0?Math.ceil(e-.5):Math.floor(e+.5)),PT=e=>rp(e*1e6),kT=(e,t)=>{let n=e.indexOf(\".\");if(n===-1)return BigInt(e)*t;let r=e[0]===\"-\",o=e.slice(n+1),s=XL**BigInt(o.length),i=(BigInt(e.slice(r?1:0,n))*s+BigInt(o))*t,a=i/s+(i%s*QL>=s?MT:wx);return r?-a:a};var t$=/^(-?\\d+(?:\\.\\d+)?)\\s+(nanos?|micros?|millis?|seconds?|minutes?|hours?|days?|weeks?)$/,mt=e=>{switch(typeof e){case\"number\":return go(e);case\"bigint\":return dr(e);case\"string\":{if(e===\"Infinity\")return Fr;if(e===\"-Infinity\")return ki;let t=t$.exec(e);if(!t)break;let[n,r,o]=t;if(o===\"nano\"||o===\"nanos\")return dr(kT(r,MT));if(o===\"micro\"||o===\"micros\")return dr(kT(r,e$));let s=Number(r);switch(o){case\"milli\":case\"millis\":return go(s);case\"second\":case\"seconds\":return s$(s);case\"minute\":case\"minutes\":return i$(s);case\"hour\":case\"hours\":return a$(s);case\"day\":case\"days\":return c$(s);case\"week\":case\"weeks\":return u$(s)}break}case\"object\":{if(e===null)break;if(np in e)return e;if(Array.isArray(e))return e.length!==2||!e.every(Ou)?RT(e):Number.isNaN(e[0])||Number.isNaN(e[1])?Ms:e[0]===-1/0||e[1]===-1/0?ki:e[0]===1/0||e[1]===1/0?Fr:mn(rp(e[0]*1e9+e[1]));let t=e,n=0;return t.weeks&&(n+=t.weeks*6048e5),t.days&&(n+=t.days*864e5),t.hours&&(n+=t.hours*36e5),t.minutes&&(n+=t.minutes*6e4),t.seconds&&(n+=t.seconds*1e3),t.milliseconds&&(n+=t.milliseconds),!t.microseconds&&!t.nanoseconds?mn(n):mn(rp(n*1e6+(t.microseconds??0)*1e3+(t.nanoseconds??0)))}}return RT(e)},RT=e=>{throw new Error(`Invalid Input: ${e}`)},FT=Rs(mt),_T={_tag:\"Millis\",millis:0},n$={_tag:\"Infinity\"},r$={_tag:\"NegativeInfinity\"},o$={[np]:np,[be](){switch(this.value._tag){case\"Millis\":{let e=this.value.millis*1e6;return Number.isFinite(e)?H(rp(e)):On(this.value.millis)}case\"Nanos\":return H(this.value.nanos);default:return Pg(this.value)}},[Se](e){return Tx(e)&&l$(this,e)},toString(){switch(this.value._tag){case\"Infinity\":return\"Infinity\";case\"NegativeInfinity\":return\"-Infinity\";case\"Nanos\":return`${this.value.nanos} nanos`;case\"Millis\":return`${this.value.millis} millis`}},toJSON(){switch(this.value._tag){case\"Millis\":return{_id:\"Duration\",_tag:\"Millis\",millis:this.value.millis};case\"Nanos\":return{_id:\"Duration\",_tag:\"Nanos\",nanos:String(this.value.nanos)};case\"Infinity\":return{_id:\"Duration\",_tag:\"Infinity\"};case\"NegativeInfinity\":return{_id:\"Duration\",_tag:\"NegativeInfinity\"}}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},mn=e=>{let t=Object.create(o$);return typeof e==\"number\"?isNaN(e)||e===0||Object.is(e,-0)?t.value=_T:Number.isFinite(e)?Number.isInteger(e)?t.value={_tag:\"Millis\",millis:e}:t.value={_tag:\"Nanos\",nanos:PT(e)}:t.value=e>0?n$:r$:e===wx?t.value=_T:t.value={_tag:\"Nanos\",nanos:e},t},Tx=e=>M(e,np),Xa=e=>e.value._tag!==\"Infinity\"&&e.value._tag!==\"NegativeInfinity\",Ku=e=>{switch(e.value._tag){case\"Millis\":return e.value.millis===0;case\"Nanos\":return e.value.nanos===wx;case\"Infinity\":case\"NegativeInfinity\":return!1}};var Ms=mn(0),Fr=mn(1/0),ki=mn(-1/0),dr=e=>mn(e);var go=e=>mn(e),s$=e=>mn(e*1e3),i$=e=>mn(e*6e4),a$=e=>mn(e*36e5),c$=e=>mn(e*864e5),u$=e=>mn(e*6048e5),_n=e=>f$(mt(e),{onMillis:_,onNanos:t=>Number(t)/1e6,onInfinity:()=>1/0,onNegativeInfinity:()=>-1/0});var Ix=e=>{let t=mt(e);switch(t.value._tag){case\"Infinity\":case\"NegativeInfinity\":throw new Error(\"Cannot convert infinite duration to nanos\");case\"Nanos\":return t.value.nanos;case\"Millis\":return PT(t.value.millis)}},UT=Rs(Ix);var f$=l(2,(e,t)=>{switch(e.value._tag){case\"Millis\":return t.onMillis(e.value.millis);case\"Nanos\":return t.onNanos(e.value.nanos);case\"Infinity\":return t.onInfinity();case\"NegativeInfinity\":return(t.onNegativeInfinity??t.onInfinity)()}}),NT=l(3,(e,t,n)=>e.value._tag===\"Infinity\"||e.value._tag===\"NegativeInfinity\"||t.value._tag===\"Infinity\"||t.value._tag===\"NegativeInfinity\"?n.onInfinity(e,t):e.value._tag===\"Millis\"?t.value._tag===\"Millis\"?n.onMillis(e.value.millis,t.value.millis):n.onNanos(Ix(e),t.value.nanos):n.onNanos(e.value.nanos,Ix(t)));var vx=(e,t)=>NT(e,t,{onMillis:(n,r)=>n===r,onNanos:(n,r)=>n===r,onInfinity:(n,r)=>n.value._tag===r.value._tag});var DT=l(2,(e,t)=>NT(e,t,{onMillis:(n,r)=>mn(n-r),onNanos:(n,r)=>mn(n-r),onInfinity:(n,r)=>{let o=n.value._tag,s=r.value._tag;return o===\"Infinity\"?s===\"Infinity\"?Ms:Fr:o===\"NegativeInfinity\"?s===\"NegativeInfinity\"?Ms:ki:s===\"Infinity\"?ki:Fr}}));var l$=l(2,(e,t)=>vx(e,t));var Ax=l(2,(e,t)=>n=>{let r=e(n);if(ie(r))return re(n);let o=t(r.success);return ie(o)?re(n):o}),LT=e=>t=>{let n=e(t);return ie(n)?R():k(n.success)};var Gu=Ae(\"effect/Scheduler\",{fiberCached:!0,defaultValue:()=>new Ri}),jT=\"setImmediate\"in globalThis?e=>{let t=globalThis.setImmediate(e);return()=>globalThis.clearImmediate(t)}:e=>{let t=setTimeout(e,0);return()=>clearTimeout(t)},d$=e=>{let t=!1;return Promise.resolve().then(()=>{t||e()}),()=>{t=!0}},Cx=class{buckets=[];scheduleTask(t,n){let r=this.buckets,o=r.length,s,i=0;for(;in);i++)s=r[i];s&&s[0]===n?s[1].push(t):i===o?r.push([n,[t]]):r.splice(i,0,[n,[t]])}drain(){let t=this.buckets;return this.buckets=[],t}},Ri=class{executionMode;setImmediate;constructor(t=\"async\",n){this.executionMode=t,this.setImmediate=n??(t===\"sync\"?d$:jT)}shouldYield(t){return t.currentOpCount>=t.maxOpsBeforeYield}makeDispatcher(){return new Ox(this.setImmediate)}},Ox=class{tasks=new Cx;running=void 0;setImmediate;constructor(t=jT){this.setImmediate=t}scheduleTask(t,n){this.tasks.scheduleTask(t,n),this.running===void 0&&(this.running=this.setImmediate(this.afterScheduled))}afterScheduled=()=>{this.running=void 0,this.runTasks()};runTasks(){let t=this.tasks.drain();for(let n=0;n0;)this.running!==void 0&&(this.running(),this.running=void 0),this.runTasks()}},BT=Ae(\"effect/Scheduler/MaxOpsBeforeYield\",{fiberCached:!0,defaultValue:()=>2048}),qT=Ae(\"effect/Scheduler/PreventSchedulerYield\",{fiberCached:!0,defaultValue:()=>!1});var bo={};uo(bo,{Class:()=>op,Error:()=>zT,TaggedClass:()=>p$,TaggedError:()=>yo,taggedEnum:()=>m$});var op=class extends li{constructor(e){super(),e&&Pd(this,e)}},p$=e=>class extends op{_tag=e},m$=()=>new Proxy({},{get(e,t,n){return t===\"$is\"?$t:t===\"$match\"?h$:r=>({...r,_tag:t})}});function h$(){if(arguments.length===1){let n=arguments[0];return function(r){return n[r._tag](r)}}let e=arguments[0];return arguments[1][e._tag](e)}var zT=Fu,yo=Wo;var WT=\"~effect/encoding/EncodingError\",Go=class extends yo(\"EncodingError\"){[WT]=WT};var ip=e=>Rx(typeof e==\"string\"?Fx.encode(e):e),tc=e=>{let t=ev(e),n=t.length;if(n%4!==0)return re(new Go({kind:\"Decode\",module:\"Base64\",input:t,message:`Length must be a multiple of 4, but is ${n}`}));let r=t.indexOf(\"=\");if(r!==-1&&(r$e()(st([J({_tag:ze(\"Some\"),value:n}),J({_tag:ze(\"None\")})]),We({decode:r=>r._tag===\"None\"?R():k(r.value),encode:r=>_e(r)?{_tag:\"Some\",value:r.value}:{_tag:\"None\"}})),toArbitrary:([n])=>(r,o)=>{let s=r.constant(R()),i=r.oneof(s,n.arbitrary.map(k));return Ra(r,o,s,i)},toEquivalence:([n])=>ex(n),toFormatter:([n])=>fr({onNone:()=>\"none()\",onSome:r=>`some(${n(r)})`})});return j(t.ast,{value:e})}var O3=wn(\"effect/schema/Option\",Re,({annotations:e,typeParameters:t})=>{let n=si(t[0]);return e===void 0?n:n.annotate(e)});function k3(e){return rw(e).pipe(ne(si(Tn(e)),oR()))}function R3(e){return ng(e).pipe(ne(si(Tn(e)),sR()))}function _3(e,t){return tU(e).pipe(ne(si(Tn(e)),iR(t)))}function M3(e){return Ca(e).pipe(ne(si(Tn(e)),aR()))}function P3(e){return Ar(e).pipe(ne(si(Tn(e)),cR()))}function F3(e,t){let n=t===void 0?\"omit\":t.onNoneEncoding,r=n===null?null:void 0;return Ar(rw(e)).pipe(ne(si(Tn(e)),Mm({decode:o=>o.pipe(Ai(vd),k),encode:n===\"omit\"?vi:o=>k(ju(vi(o),()=>r))})))}function YU(e,t){let n=Nn()([e,t],([r,o])=>(s,i,a)=>{if(!X0(s))return P(new He(i,s,a));switch(s._tag){case\"Success\":return Ut(OI(r)(s.success,a),{onSuccess:se,onFailure:c=>Pn(i,\"success\",c,s,a)});case\"Failure\":return Ut(OI(o)(s.failure,a),{onSuccess:re,onFailure:c=>Pn(i,\"failure\",c,s,a)})}},{representation:{id:\"effect/schema/Result\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Result(${r[0].runtime}, ${r[1].runtime})`,Type:`Result.Result<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Result from \"effect/Result\"']}),expected:\"Result\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Success\"),success:r}),J({_tag:ze(\"Failure\"),failure:o})]),We({decode:s=>s._tag===\"Success\"?se(s.success):re(s.failure),encode:s=>Tt(s)?{_tag:\"Success\",success:s.success}:{_tag:\"Failure\",failure:s.failure}})),toArbitrary:([r,o])=>(s,i)=>{let a=cN(s,r.terminal?.map(u=>se(u)),o.terminal?.map(u=>re(u))),c=s.oneof(r.arbitrary.map(u=>se(u)),o.arbitrary.map(u=>re(u)));return Ra(s,i,a,c)},toEquivalence:([r,o])=>eT(r,o),toFormatter:([r,o])=>lr({onSuccess:s=>`success(${r(s)})`,onFailure:s=>`failure(${o(s)})`})});return j(n.ast,{success:e,failure:t})}var U3=wn(\"effect/schema/Result\",Re,({annotations:e,typeParameters:t})=>{let n=YU(t[0],t[1]);return e===void 0?n:n.annotate(e)}),N3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>{switch(n){case\"label\":return typeof e[n]==\"string\";case\"disallowJsonEncode\":return e[n]===!0;default:return!1}})}),D3=st([Re,N3]);function ed(e,t){let n=typeof t?.label==\"string\"?t.label:void 0,r=t?.disallowJsonEncode===!0,o=n!==void 0?r?{label:n,disallowJsonEncode:!0}:{label:n}:r?{disallowJsonEncode:!0}:void 0,s=n!==void 0?Ze(ze(n)):void 0,i=Nn()([e],([a])=>(c,u,f)=>{if(GI(c)){let d=s!==void 0?fs(s(c.label,f),p=>new Ve([\"label\"],p)):te;return lt(d,()=>Ut(Ze(a)(eu(c),f),{onSuccess:()=>c,onFailure:()=>new nt(u,[new Ve([\"value\"],new ge(void 0,c,f))],c,f)}))}return P(new He(u,c,f))},{representation:{id:\"effect/schema/Redacted\",payload:o??null},toCode:({typeParameters:a})=>({runtime:o!==void 0?`Schema.Redacted(${a[0].runtime}, ${N(o)})`:`Schema.Redacted(${a[0].runtime})`,Type:`Redacted.Redacted<${a[0].Type}>`,importDeclarations:['import * as Redacted from \"effect/Redacted\"']}),expected:\"Redacted\",toCodecJson:([a])=>$e()(a,{decode:ce(c=>zl(c,{label:n})),encode:r?ES(c=>\"Cannot serialize Redacted\"+(_e(c)&&typeof c.value.label==\"string\"?` with label: \"${c.value.label}\"`:\"\")):ce(eu)}),toArbitrary:([a])=>()=>({arbitrary:a.arbitrary.map(c=>zl(c,{label:n})),terminal:a.terminal?.map(c=>zl(c,{label:n}))}),toFormatter:()=>globalThis.String,toEquivalence:([a])=>SF(a)});return j(i.ast,{value:e})}var L3=wn(\"effect/schema/Redacted\",D3,({annotations:e,payload:t,typeParameters:n})=>{let r=ed(n[0],t??void 0);return e===void 0?r:r.annotate(e)});function $3(e,t){return ne(ed(Tn(e),{label:t?.label,disallowJsonEncode:t?.disallowEncode}),{decode:ce(n=>zl(n,{label:t?.label})),encode:t?.disallowEncode?ES(n=>\"Cannot encode Redacted\"+(_e(n)&&typeof n.value.label==\"string\"?` with label: \"${n.value.label}\"`:\"\")):ce(eu)})(e)}function Vh(e,t){let n=Nn()([e,t],([r,o])=>(s,i,a)=>{if(!eb(s))return P(new He(i,s,a));switch(s._tag){case\"Fail\":return Ut(Ze(r)(s.error,a),{onSuccess:Ef,onFailure:c=>Pn(i,\"error\",c,s,a)});case\"Die\":return Ut(Ze(o)(s.defect,a),{onSuccess:If,onFailure:c=>Pn(i,\"defect\",c,s,a)});case\"Interrupt\":return x(s)}},{representation:{id:\"effect/schema/CauseReason\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.CauseReason(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Failure<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause.Failure\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Fail\"),error:r}),J({_tag:ze(\"Die\"),defect:o}),J({_tag:ze(\"Interrupt\"),fiberId:ng(Xn)})]),We({decode:s=>{switch(s._tag){case\"Fail\":return Ef(s.error);case\"Die\":return If(s.defect);case\"Interrupt\":return wf(s.fiberId)}},encode:_})),toArbitrary:([r,o])=>QU(r,o),toEquivalence:([r,o])=>XU(r,o),toFormatter:([r,o])=>eN(r,o)});return j(n.ast,{error:e,defect:t})}var j3=wn(\"effect/schema/CauseReason\",Re,({annotations:e,typeParameters:t})=>{let n=Vh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function QU(e,t){return(n,r)=>{let o=n.constant(wf()),s=n.oneof(o,n.integer({min:1}).map(wf),e.arbitrary.map(i=>Ef(i)),t.arbitrary.map(i=>If(i)));return Ra(n,r,o,s)}}function XU(e,t){return(n,r)=>{if(n._tag!==r._tag)return!1;switch(n._tag){case\"Fail\":return e(n.error,r.error);case\"Die\":return t(n.defect,r.defect);case\"Interrupt\":return n.fiberId===r.fiberId}}}function eN(e,t){return n=>{switch(n._tag){case\"Fail\":return`Fail(${e(n.error)})`;case\"Die\":return`Die(${t(n.defect)})`;case\"Interrupt\":return\"Interrupt\"}}}function Zh(e,t){let n=Nn()([e,t],([r,o])=>{let s=Ye(Vh(r,o));return(i,a,c)=>Xy(i)?Ut(Ze(s)(i.reasons,c),{onSuccess:ji,onFailure:u=>Pn(a,\"failures\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/Cause\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Cause(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Cause<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause\",toCodec:([r,o])=>$e()(Ye(Vh(r,o)),We({decode:ji,encode:({reasons:s})=>s})),toArbitrary:([r,o])=>tN(r,o),toEquivalence:([r,o])=>nN(r,o),toFormatter:([r,o])=>rN(r,o)});return j(n.ast,{error:e,defect:t})}var B3=wn(\"effect/schema/Cause\",Re,({annotations:e,typeParameters:t})=>{let n=Zh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function tN(e,t){return(n,r)=>{let o=QU(e,t)(n,r),s=n.constant(tb),i=n.array(o.arbitrary).map(ji);return Ra(n,r,s,i)}}function nN(e,t){let n=A0(XU(e,t));return(r,o)=>n(r.reasons,o.reasons)}function rN(e,t){let n=eN(e,t);return r=>`Cause([${r.reasons.map(n).join(\", \")}])`}var q3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>(n===\"includeStack\"||n===\"excludeCause\")&&e[n]===!0)}),z3=st([Re,q3]),oN=e=>(e?.includeStack===!0?1:0)|(e?.excludeCause===!0?2:0),sN=e=>{switch(e){case 0:return;case 1:return{includeStack:!0};case 2:return{excludeCause:!0};case 3:return{includeStack:!0,excludeCause:!0}}},RF=[];function iN(e){let t=oN(e),n=RF[t];if(n!==void 0)return n;let r=sN(t),o=oi(globalThis.Error,{representation:{id:\"effect/schema/Error\",payload:r??null},toCode:()=>({runtime:r!==void 0?`Schema.ErrorInstance(${N(r)})`:\"Schema.ErrorInstance()\",Type:\"globalThis.Error\"}),expected:\"Error\",toCodecJson:()=>$e()(N6,nR(r)),toArbitrary:()=>s=>s.string().map(i=>new globalThis.Error(i))});return RF[t]=o,o}var W3=wn(\"effect/schema/Error\",z3,({annotations:e,payload:t})=>{let n=iN(t??void 0);return e===void 0?n:n.annotate(e)}),_F=[];function H3(e){let t=oN(e),n=_F[t];if(n!==void 0)return n;let r=id.pipe(ne(ew,rR(sN(t))));return _F[t]=r,r}function aN(e,t,n){let r=Nn()([e,t,n],([o,s,i])=>{let a=Zh(s,i);return(c,u,f)=>{if(!vf(c))return P(new He(u,c,f));switch(c._tag){case\"Success\":return Ut(Ze(o)(c.value,f),{onSuccess:Yt,onFailure:d=>Pn(u,\"value\",d,c,f)});case\"Failure\":return Ut(Ze(a)(c.cause,f),{onSuccess:Qt,onFailure:d=>Pn(u,\"cause\",d,c,f)})}}},{representation:{id:\"effect/schema/Exit\",payload:null},toCode:({typeParameters:o})=>({runtime:`Schema.Exit(${o[0].runtime}, ${o[1].runtime}, ${o[2].runtime})`,Type:`Exit.Exit<${o[0].Type}, ${o[1].Type}, ${o[2].Type}>`,importDeclarations:['import * as Exit from \"effect/Exit\"']}),expected:\"Exit\",toCodec:([o,s,i])=>$e()(st([J({_tag:ze(\"Success\"),value:o}),J({_tag:ze(\"Failure\"),cause:Zh(s,i)})]),We({decode:a=>a._tag===\"Success\"?Yt(a.value):Qt(a.cause),encode:a=>et(a)?{_tag:\"Success\",value:a.value}:{_tag:\"Failure\",cause:a.cause}})),toArbitrary:([o,s,i])=>(a,c)=>{let u=tN(s,i)(a,c),f=cN(a,o.terminal?.map(p=>Yt(p)),u.terminal?.map(p=>Qt(p))),d=a.oneof(o.arbitrary.map(p=>Yt(p)),u.arbitrary.map(p=>Qt(p)));return Ra(a,c,f,d)},toEquivalence:([o,s,i])=>{let a=nN(s,i);return(c,u)=>{if(c._tag!==u._tag)return!1;switch(c._tag){case\"Success\":return o(c.value,u.value);case\"Failure\":return a(c.cause,u.cause)}}},toFormatter:([o,s,i])=>{let a=rN(s,i);return c=>{switch(c._tag){case\"Success\":return`Exit.Success(${o(c.value)})`;case\"Failure\":return`Exit.Failure(${a(c.cause)})`}}}});return j(r.ast,{value:e,error:t,defect:n})}var J3=wn(\"effect/schema/Exit\",Re,({annotations:e,typeParameters:t})=>{let n=aN(t[0],t[1],t[2]);return e===void 0?n:n.annotate(e)});function cN(e,t,n){return t===void 0?n:n===void 0?t:e.oneof(t,n)}function Ra(e,t,n,r){return{arbitrary:n===void 0||t.recursion===void 0?r:e.oneof(t.recursion,n,r),terminal:n}}function MF(e,t,n,r){return r===void 0?e.array(t,n):e.uniqueArray(t,{...n,comparator:r})}function og(e,t,n,r,o,s){let i=t.constraint,a=i===void 0||i.minLength===void 0&&i.maxLength===void 0?void 0:{...i.minLength!==void 0?{minLength:i.minLength}:{},...i.maxLength!==void 0?{maxLength:i.maxLength}:{}};if(a?.minLength!==void 0&&a.maxLength!==void 0&&a.minLength>a.maxLength)throw new globalThis.Error(\"Unable to derive an arbitrary for size constraints\");let c=a?.minLength??0,u=c===0?e.constant([]):r===void 0?void 0:MF(e,r,{...a,maxLength:c},s),f=Ra(e,t,u,MF(e,n,a,s));return{arbitrary:f.arbitrary.map(o),terminal:f.terminal?.map(o)}}function uN(e,t,n,r,o){return og(e,t,e.tuple(n.arbitrary,r.arbitrary),n.terminal===void 0||r.terminal===void 0?void 0:e.tuple(n.terminal,r.terminal),o,([s],[i])=>B(s,i))}function fN(e,t){let n=Nn()([e,t],([r,o])=>{let s=Ye(nu([r,o]));return(i,a,c)=>i instanceof globalThis.Map?Ut(Ze(s)([...i],c),{onSuccess:u=>new globalThis.Map(u),onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/ReadonlyMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.ReadonlyMap(${r[0].runtime}, ${r[1].runtime})`,Type:`globalThis.ReadonlyMap<${r[0].Type}, ${r[1].Type}>`}),expected:\"ReadonlyMap\",toCodec:([r,o])=>$e()(Ye(nu([r,o])),We({decode:s=>new globalThis.Map(s),encode:s=>[...s.entries()]})),toArbitrary:([r,o])=>(s,i)=>uN(s,i,r,o,a=>new globalThis.Map(a)),toEquivalence:([r,o])=>Od(r,o),toFormatter:([r,o])=>s=>{let i=s.size;if(i===0)return\"ReadonlyMap(0) {}\";let a=globalThis.Array.from(s.entries()).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`ReadonlyMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var K3=wn(\"effect/schema/ReadonlyMap\",Re,({annotations:e,typeParameters:t})=>{let n=fN(t[0],t[1]);return e===void 0?n:n.annotate(e)});function PF(e,t,n){return J({type:ze(e),nodes:Ye(J({index:Gt,data:t})),edges:Ye(J({index:Gt,source:Gt,target:Gt,data:n}))})}function FF(e,t){let n=-1,r=new Set;for(let o=0;o{let o=Ol(n),s=Ol(r);if(o.type!==s.type||o.nodes.length!==s.nodes.length||o.edges.length!==s.edges.length)return!1;for(let i=0;i{let s=kl({type:e,nodes:[],edges:[]}),i=r.constant(s),a=r.array(t.arbitrary).chain(c=>{let u=c.map((d,p)=>({index:p,data:d}));if(u.length===0)return i;let f=r.integer({min:0,max:u.length-1});return r.array(r.tuple(f,f,n.arbitrary)).map(d=>kl({type:e,nodes:u,edges:d.map(([p,m,g],b)=>({index:b,source:p,target:m,data:g}))}))});return Ra(r,o,i,a)}}function lN(e,t,n){let r=Nn()([t,n],([o,s])=>{let i=PF(e,o,s);return(a,c,u)=>!xI(a)||a.mutable||a.type!==e?P(new He(c,a,u)):v(Ze(i)(Ol(a),u),f=>FF(f,u))},{representation:{id:\"effect/schema/Graph\",payload:e},toCode:({typeParameters:o})=>({runtime:`Schema.Graph(${N(e)}, ${o[0].runtime}, ${o[1].runtime})`,Type:`Graph.Graph<${o[0].Type}, ${o[1].Type}, ${N(e)}>`,importDeclarations:['import * as Graph from \"effect/Graph\"']}),expected:`an immutable ${e} Graph`,toCodec:([o,s])=>$e()(PF(e,o,s),Zn({decode:FF,encode:(i,a)=>G3(i,e,a)})),toArbitrary:([o,s])=>Z3(e,o,s),toEquivalence:([o,s])=>V3(o,s),toFormatter:()=>globalThis.String});return j(r.ast,{type:e,node:t,edge:n})}var Y3=wn(\"effect/schema/Graph\",so([\"directed\",\"undirected\"]),({annotations:e,payload:t,typeParameters:n})=>{let r=lN(t,n[0],n[1]);return e===void 0?r:r.annotate(e)});function dN(e,t){let n=Nn()([e,t],([r,o])=>{let s=Ye(nu([r,o]));return(i,a,c)=>QM(i)?Ut(Ze(s)(Fh(i),c),{onSuccess:Ph,onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/HashMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.HashMap(${r[0].runtime}, ${r[1].runtime})`,Type:`HashMap.HashMap<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as HashMap from \"effect/HashMap\"']}),expected:\"HashMap\",toCodec:([r,o])=>$e()(Ye(nu([r,o])),We({decode:Ph,encode:Fh})),toArbitrary:([r,o])=>(s,i)=>uN(s,i,r,o,Ph),toEquivalence:([r,o])=>Od(r,o),toFormatter:([r,o])=>s=>{let i=XM(s);if(i===0)return\"HashMap(0) {}\";let a=Fh(s).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`HashMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var Q3=wn(\"effect/schema/HashMap\",Re,({annotations:e,typeParameters:t})=>{let n=dN(t[0],t[1]);return e===void 0?n:n.annotate(e)});function pN(e){let t=Nn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>o instanceof globalThis.Set?Ut(Ze(r)([...o],i),{onSuccess:a=>new globalThis.Set(a),onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/ReadonlySet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.ReadonlySet(${n[0].runtime})`,Type:`globalThis.ReadonlySet<${n[0].Type}>`}),expected:\"ReadonlySet\",toCodec:([n])=>$e()(Ye(n),We({decode:r=>new globalThis.Set(r),encode:r=>[...r.values()]})),toArbitrary:([n])=>(r,o)=>og(r,o,n.arbitrary,n.terminal,s=>new globalThis.Set(s),B),toEquivalence:([n])=>kd(n),toFormatter:([n])=>r=>{let o=r.size;if(o===0)return\"ReadonlySet(0) {}\";let s=globalThis.Array.from(r.values()).sort().map(i=>`${n(i)}`);return`ReadonlySet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var X3=wn(\"effect/schema/ReadonlySet\",Re,({annotations:e,typeParameters:t})=>{let n=pN(t[0]);return e===void 0?n:n.annotate(e)});function mN(e){let t=Nn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>oP(o)?Ut(Ze(r)(Be(o),i),{onSuccess:Nh,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/HashSet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.HashSet(${n[0].runtime})`,Type:`HashSet.HashSet<${n[0].Type}>`}),expected:\"HashSet\",toCodec:([n])=>$e()(Ye(n),We({decode:Nh,encode:Be})),toArbitrary:([n])=>(r,o)=>og(r,o,n.arbitrary,n.terminal,Nh,B),toEquivalence:([n])=>kd(n),toFormatter:([n])=>r=>{let o=sP(r);if(o===0)return\"HashSet(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`HashSet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var eG=wn(\"effect/schema/HashSet\",Re,({annotations:e,typeParameters:t})=>{let n=mN(t[0]);return e===void 0?n:n.annotate(e)});function hN(e){let t=Nn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>Qm(o)?Ut(Ze(r)(Be(o),i),{onSuccess:Xm,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/Chunk\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.Chunk(${n[0].runtime})`,Type:`Chunk.Chunk<${n[0].Type}>`}),expected:\"Chunk\",toCodec:([n])=>$e()(Ye(n),We({decode:Xm,encode:Be})),toArbitrary:([n])=>(r,o)=>og(r,o,n.arbitrary,n.terminal,Xm),toEquivalence:([n])=>TE(n),toFormatter:([n])=>r=>{let o=l_(r);if(o===0)return\"Chunk(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`Chunk(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var tG=wn(\"effect/schema/Chunk\",Re,({annotations:e,typeParameters:t})=>{let n=hN(t[0]);return e===void 0?n:n.annotate(e)}),gN=oi(globalThis.RegExp,{representation:{id:\"effect/schema/RegExp\",payload:null},toCode:()=>({runtime:\"Schema.RegExp\",Type:\"globalThis.RegExp\"}),expected:\"RegExp\",toCodecJson:()=>$e()(J({source:X,flags:X}),Zn({decode:(e,t)=>yc({try:()=>new globalThis.RegExp(e.source,e.flags),catch:()=>new ge({expected:\"valid RegExp source and flags\"},e,t)}),encode:e=>x({source:e.source,flags:e.flags})})),toArbitrary:()=>e=>e.tuple(e.constantFrom(\".\",\".*\",\"\\\\d+\",\"\\\\w+\",\"[a-z]+\",\"[A-Z]+\",\"[0-9]+\",\"^[a-zA-Z0-9]+$\",\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"),e.uniqueArray(e.constantFrom(\"g\",\"i\",\"m\",\"s\",\"u\",\"y\"),{minLength:0,maxLength:6}).map(t=>t.join(\"\"))).map(([t,n])=>new globalThis.RegExp(t,n)),toEquivalence:()=>(e,t)=>e.source===t.source&&e.flags===t.flags}),nG=sn(\"effect/schema/RegExp\",gN),xN=X.annotate({expected:\"a string that will be decoded as a URL\"}),td=oi(globalThis.URL,{representation:{id:\"effect/schema/URL\",payload:null},toCode:()=>({runtime:\"Schema.URL\",Type:\"globalThis.URL\"}),expected:\"URL\",toCodecJson:()=>$e()(xN,OS),toArbitrary:()=>e=>e.webUrl().map(t=>new globalThis.URL(t)),toEquivalence:()=>(e,t)=>e.toString()===t.toString()}),rG=sn(\"effect/schema/URL\",td),oG=xN.pipe(ne(td,OS));function pw(e,t,n){let r={...t};if(e?.minimum!==void 0){let o=n===void 0?e.minimum:n(e.minimum),s=e.exclusiveMinimum?new globalThis.Date(o.getTime()+1):o;(r.min===void 0||s.getTime()>r.min.getTime())&&(r.min=s)}if(e?.maximum!==void 0){let o=n===void 0?e.maximum:n(e.maximum),s=e.exclusiveMaximum?new globalThis.Date(o.getTime()-1):o;(r.max===void 0||s.getTime()e instanceof globalThis.Date&&!globalThis.Number.isNaN(e.getTime()),{representation:{id:\"effect/schema/Date\",payload:null},toCode:()=>({runtime:\"Schema.Date\",Type:\"globalThis.Date\"}),expected:\"a valid Date\",toCodecJson:()=>$e()(yN,CS),toArbitrary:()=>(e,t)=>e.date(pw(t?.constraint?.ordered?.order===Ii?t.constraint.ordered:void 0,{noInvalidDate:!0}))}),sG=sn(\"effect/schema/Date\",er),iG=yN.pipe(ne(er,CS)),aG=Uo.pipe(ne(er,Vk)),nd=Cr(Tx,{representation:{id:\"effect/schema/Duration\",payload:null},toCode:()=>({runtime:\"Schema.Duration\",Type:\"Duration.Duration\",importDeclarations:['import * as Duration from \"effect/Duration\"']}),expected:\"Duration\",toCodecJson:()=>$e()(st([J({_tag:ze(\"Infinity\")}),J({_tag:ze(\"NegativeInfinity\")}),J({_tag:ze(\"Nanos\"),value:Fo}),J({_tag:ze(\"Millis\"),value:Uo})]),We({decode:e=>{switch(e._tag){case\"Infinity\":return Fr;case\"NegativeInfinity\":return ki;case\"Nanos\":return dr(e.value);case\"Millis\":return go(e.value)}},encode:e=>{switch(e.value._tag){case\"Infinity\":return{_tag:\"Infinity\"};case\"NegativeInfinity\":return{_tag:\"NegativeInfinity\"};case\"Nanos\":return{_tag:\"Nanos\",value:e.value.nanos};case\"Millis\":return{_tag:\"Millis\",value:e.value.millis}}}})),toArbitrary:()=>e=>e.oneof(e.constant(Fr),e.constant(ki),e.bigInt().map(dr),e.maxSafeInteger().map(go)),toFormatter:()=>globalThis.String,toEquivalence:()=>vx}),cG=sn(\"effect/schema/Duration\",nd),uG=X.annotate({expected:\"a string that will be decoded as a Duration\"}),mw=uG.pipe(ne(nd,Zk)),fG=Fo.pipe(ne(nd,Yk)),lG=au.pipe(ne(nd,Qk)),bN=X.annotate({expected:\"a string that will be decoded as a BigDecimal\"}),SN=20,EN=\"Unable to derive an arbitrary for the ordered BigDecimal constraints\";function Yh(e,t){return _s(e,t).value}function dG(e,t,n){return n?Yh(mx(e,t),t)+globalThis.BigInt(1):Yh(px(e,t),t)}function pG(e,t,n){return n?Yh(px(e,t),t)-globalThis.BigInt(1):Yh(mx(e,t),t)}function mG(e){return Math.max(SN,e.minimum?.scale??0,e.maximum?.scale??0,e.exclusiveMinimum&&e.minimum!==void 0?e.minimum.scale+1:0,e.exclusiveMaximum&&e.maximum!==void 0?e.maximum.scale+1:0)}function ZI(e,t){let n={};if(e.minimum!==void 0&&(n.min=dG(e.minimum,t,e.exclusiveMinimum===!0)),e.maximum!==void 0&&(n.max=pG(e.maximum,t,e.exclusiveMaximum===!0)),!(n.min!==void 0&&n.max!==void 0&&n.min>n.max))return n}function hG(e){let t=mG(e);if(ZI(e,t)===void 0)throw new globalThis.Error(EN);let n=0,r=t;for(;n({runtime:\"Schema.BigDecimal\",Type:\"BigDecimal.BigDecimal\",importDeclarations:['import * as BigDecimal from \"effect/BigDecimal\"']}),expected:\"BigDecimal\",toCodecJson:()=>$e()(bN,kS),toArbitrary:()=>(e,t)=>{let n=t.constraint?.ordered?.order===Jo?t.constraint.ordered:void 0;return n===void 0?e.tuple(e.bigInt(),e.integer({min:0,max:SN})).map(([r,o])=>Rn(r,o)):e.integer(hG(n)).chain(r=>{let o=ZI(n,r);if(o===void 0)throw new globalThis.Error(EN);return e.bigInt(o).map(s=>Rn(s,r))})},toFormatter:()=>e=>mo(e),toEquivalence:()=>lx}),gG=sn(\"effect/schema/BigDecimal\",hw),xG=bN.pipe(ne(hw,kS)),yG=X.annotate({expected:\"a string that will be decoded as JSON\",contentMediaType:\"application/json\"});function IN(e,t){return yG.pipe(ne(e,pR(t)))}var bG=IN(ew),gw=oi(globalThis.File,{representation:{id:\"effect/schema/File\",payload:null},toCode:()=>({runtime:\"Schema.File\",Type:\"globalThis.File\"}),expected:\"File\",toCodecJson:()=>$e()(J({data:X.check(aw()),type:X,name:X,lastModified:Uo}),Zn({decode:(e,t)=>lr(tc(e.data),{onFailure:()=>P(new ge({expected:\"a valid Base64 string\"},e.data,t)),onSuccess:n=>{let r=new globalThis.Uint8Array(n);return x(new globalThis.File([r],e.name,{type:e.type,lastModified:e.lastModified}))}}),encode:(e,t)=>xc({try:async()=>{let n=new globalThis.Uint8Array(await e.arrayBuffer());return{data:ip(n),type:e.type,name:e.name,lastModified:e.lastModified}},catch:()=>new ge({expected:\"a readable File\"},e,t)})}))}),SG=sn(\"effect/schema/File\",gw),xw=oi(globalThis.FormData,{representation:{id:\"effect/schema/FormData\",payload:null},toCode:()=>({runtime:\"Schema.FormData\",Type:\"globalThis.FormData\"}),expected:\"FormData\",toCodecJson:()=>$e()(Ye(nu([X,st([J({_tag:Oa(\"String\"),value:X}),J({_tag:Oa(\"File\"),value:gw})])])),Zn({decode:e=>{let t=new globalThis.FormData;for(let[n,r]of e)t.append(n,r.value);return x(t)},encode:e=>x(globalThis.Array.from(e.entries()).map(([t,n])=>typeof n==\"string\"?[t,{_tag:\"String\",value:n}]:[t,{_tag:\"File\",value:n}]))}))}),EG=sn(\"effect/schema/FormData\",xw);function IG(e){return xw.pipe(ne(e,mR))}var yw=oi(globalThis.URLSearchParams,{representation:{id:\"effect/schema/URLSearchParams\",payload:null},toCode:()=>({runtime:\"Schema.URLSearchParams\",Type:\"globalThis.URLSearchParams\"}),expected:\"URLSearchParams\",toCodecJson:()=>$e()(X.annotate({expected:\"a query string that will be decoded as URLSearchParams\"}),We({decode:e=>new globalThis.URLSearchParams(e),encode:e=>e.toString()}))}),wG=sn(\"effect/schema/URLSearchParams\",yw);function TG(e){return yw.pipe(ne(e,hR))}var vG=X.annotate({expected:\"a string that will be decoded as a number\"}).pipe(ne(au,kc)),AG=X.annotate({expected:\"a string that will be decoded as a finite number\"}).pipe(ne(Xn,kc)),CG=j(xE).pipe(ne(Fo,Pm)),wN=X.check(iw()),OG=X.annotate({expected:\"a string that will be decoded as a trimmed string\"}).pipe(ne(wN,Kk())),kG=X.annotate({expected:\"a base64 encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,uR)),RG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,fR)),_G=X.annotate({expected:\"a hex encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,lR)),MG=X.annotate({expected:\"a URI component encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,dR)),YI=st([Xn,QF,X]),PG=J({issues:Ye(J({message:X,path:Ar(Ye(st([YI,J({key:YI})])))}))}),FG=so([0,1]).pipe(ne(tg,We({decode:e=>e===1,encode:e=>e?1:0}))),TN=X.annotate({expected:\"a base64 encoded string that will be decoded as Uint8Array\",format:\"byte\",contentEncoding:\"base64\"}),rd=oi(globalThis.Uint8Array,{representation:{id:\"effect/schema/Uint8Array\",payload:null},toCode:()=>({runtime:\"Schema.Uint8Array\",Type:\"globalThis.Uint8Array\"}),expected:\"Uint8Array\",toCodecJson:()=>$e()(TN,RS),toArbitrary:()=>e=>e.uint8Array()}),UG=sn(\"effect/schema/Uint8Array\",rd),NG=TN.pipe(ne(rd,RS)),DG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a Uint8Array\"}).pipe(ne(rd,{decode:Uk(),encode:km()})),LG=X.annotate({expected:\"a hex encoded string that will be decoded as a Uint8Array\"}).pipe(ne(rd,{decode:Dk(),encode:Rm()})),od=Cr(e=>xS(e)&&hk(e),{representation:{id:\"effect/schema/DateTimeUtc\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeUtc\",Type:\"DateTime.Utc\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Utc\",toCodecJson:()=>$e()(X,PS),toArbitrary:()=>(e,t)=>e.date(pw(t?.constraint?.ordered?.order===bS?t.constraint.ordered:void 0,{noInvalidDate:!0},Tm)).map(n=>xk(n)),toFormatter:()=>e=>e.toString(),toEquivalence:()=>yS}),$G=sn(\"effect/schema/DateTimeUtc\",od),jG=er.pipe(ne(od,{decode:AS(),encode:ce(Tm)})),BG=X.annotate({expected:\"a string that will be decoded as a DateTime.Utc\"}).pipe(ne(od,PS)),qG=Uo.pipe(ne(od,{decode:AS(),encode:ce(Ik)})),vN=Cr(pk,{representation:{id:\"effect/schema/TimeZoneOffset\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneOffset\",Type:\"DateTime.TimeZone.Offset\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Offset\",toCodecJson:()=>$e()(Uo,gR),toArbitrary:()=>e=>e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Qf(t)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.offset===t.offset}),zG=sn(\"effect/schema/TimeZoneOffset\",vN),AN=X.annotate({expected:\"an IANA time zone identifier\"}),bw=Cr(mk,{representation:{id:\"effect/schema/TimeZoneNamed\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneNamed\",Type:\"DateTime.TimeZone.Named\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Named\",toCodecJson:()=>$e()(AN,_S),toArbitrary:()=>e=>e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(SS)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.id===t.id}),WG=sn(\"effect/schema/TimeZoneNamed\",bw),HG=AN.pipe(ne(bw,_S)),CN=X.annotate({expected:\"a time zone string (IANA identifier or offset like +03:00)\"}),Sw=Cr(dk,{representation:{id:\"effect/schema/TimeZone\",payload:null},toCode:()=>({runtime:\"Schema.TimeZone\",Type:\"DateTime.TimeZone\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone\",toCodecJson:()=>$e()(CN,MS),toArbitrary:()=>e=>e.oneof(e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Qf(t)),e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(SS))),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>Vs(e)===Vs(t)}),JG=sn(\"effect/schema/TimeZone\",Sw),KG=CN.pipe(ne(Sw,MS)),ON=X.annotate({expected:\"a zoned DateTime string (e.g. 2024-01-01T00:00:00.000+00:00[Europe/London])\"}),Ew=Cr(e=>xS(e)&&gk(e),{representation:{id:\"effect/schema/DateTimeZoned\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeZoned\",Type:\"DateTime.Zoned\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Zoned\",toCodecJson:()=>$e()(ON,FS),toArbitrary:()=>(e,t)=>e.tuple(e.date(pw(t?.constraint?.ordered?.order===bS?t.constraint.ordered:void 0,{max:new globalThis.Date(864e13-840*60*1e3),min:new globalThis.Date(-864e13+840*60*1e3),noInvalidDate:!0},Tm)),e.constantFrom(\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\")).map(([n,r])=>yk(n,{timeZone:r})),toFormatter:()=>e=>vm(e),toEquivalence:()=>yS}),GG=sn(\"effect/schema/DateTimeZoned\",Ew),VG=ON.pipe(ne(Ew,FS)),ZG=globalThis.Symbol.for(\"immer-draftable\"),QI={};function Iw(e,t,n,r,o){let s=QG(n,t,r),i=kN(t),a=class extends e{constructor(...[c,u]){let d=u?.[\"~payload\"],p=d?.token===QI?d.value:n.make(c??{},u);super(p,{...u,disableChecks:!0,\"~payload\":{token:QI,value:p}})}static[Wl]=Wl;get[i](){return i}static[ZG]=!0;static identifier=t;static fields=n.fields;static get ast(){return s(this).ast}static pipe(){return K(this,arguments)}static rebuild(c){return s(this).rebuild(c)}static make(c,u){return new this(c,u)}static makeOption(c,u){return Lh(s(this))(c??{},u)}static makeEffect(c,u){return s(this).makeEffect(c??{},u)}static annotate(c){return this.rebuild(Ir(this.ast,c))}static annotateKey(c){return this.rebuild(dl(this.ast,c))}static check(...c){return this.rebuild(ko(this.ast,c))}static extend(c){return(u,f)=>{let d=sd(u)?u:J(u),p={...n.fields,...d.fields},m=qm(p,n.ast.checks,{identifier:c});return Iw(this,c,nw(ko(m,d.ast.checks),p),f,o)}}static mapFields(c,u){return n.mapFields(c,u)}};return o!==void 0&&Object.assign(a.prototype,o(t)),a}function YG(e){return new Te(ce(t=>new e(t,{\"~payload\":{token:QI,value:t}})),Sn())}function kN(e){return`~effect/Schema/Class/${e}`}function QG(e,t,n){let r;return o=>{if(r!==void 0)return r;let s=kN(t),i=u=>u instanceof o||M(u,s),a=YG(o),c=j(new sa([e.ast],()=>(u,f,d)=>i(u)?x(u):P(new He(f,u,d)),{identifier:t,[gm]:([u])=>({isConstructed:i,link:new Je(u,a)}),toCodec:([u])=>new Je(u.ast,a),toArbitrary:([u])=>()=>({arbitrary:u.arbitrary.map(f=>new o(f)),terminal:u.terminal?.map(f=>new o(f))}),toFormatter:([u])=>f=>`${o.identifier}(${u(f)})`,[Jf]:Mc(e.ast),...n}));return r=ne(c,a)(e)}}function sd(e){return eg(e)}var RN=e=>(t,n)=>{let r=sd(t)?t:J(t);return Iw(op,e,r,n,o=>({toString(){return`${o}(${N({...this})})`}}))},XG=e=>(t,n,r)=>{let o=sd(n)?n.mapFields(s=>({_tag:Oa(t),...s}),{unsafePreserveChecks:!0}):rg(t,n);return RN(e??t)(o,r)},_N=e=>(t,n)=>{let r=sd(t)?t:J(t);return Iw(Fu,e,r,n,s=>({name:s}))},e6=e=>(t,n,r)=>{let o=sd(n)?n.mapFields(s=>({_tag:Oa(t),...s}),{unsafePreserveChecks:!0}):rg(t,n);return _N(e??t)(o,r)};function t6(e){let t=JP(e.ast);return n=>t(n,{})}function n6(e){return t=>t.annotate({toFormatter:e})}function r6(e,t){return n(e.ast);function n(o){let s=Co(o)?.toFormatter;if(typeof s==\"function\")return s(Qs(o)?o.typeParameters.map(n):[]);if(t?.onBefore){let i=t.onBefore(o,n);if(i!==void 0)return i}return r(o)}function r(o){switch(o._tag){default:return N;case\"Never\":return()=>\"never\";case\"Void\":return()=>\"void\";case\"Arrays\":{let s=o.elements.map(n),i=o.rest.map(n);return a=>{let c=[],u=0;for(;u0){let[f,...d]=i;for(;un(a.type)),i=o.indexSignatures.map(a=>n(a.type));return o.propertySignatures.length===0&&o.indexSignatures.length===0?N:a=>{let c=[],u=new Set;for(let f=0;f0?\"{ \"+c.join(\", \")+\" }\":\"{}\"}}case\"Union\":{let s=Jt(o).types,i=c=>al(c,s),a=new Map(s.map((c,u)=>[c,[Ea(c),n(o.types[u])]]));return c=>{let u=i(c);for(let f=0;fn(o.thunk()));return i=>s()(i)}}}}function o6(e){return t=>t.annotate({toEquivalence:e})}function s6(e){return KP(e.ast)}function i6(e,t){return qI(e.ast,t)}function MN(e,t){let n=qI(sg(e.ast),t);return cF(n,t)}function PN(e){return j(sg(e.ast),{schema:e})}var sg=fa(e=>{let t=c6(e,sg),n=e.context;return t===e||n===void 0?t:Gm(t,ww(n))});function ww(e){return e.constructorDefault===void 0?e:new Er(e.isOptional,e.isMutable,void 0,e.annotations)}function FN(e){if(e.propertySignatures.some(t=>typeof t.name!=\"string\"))throw new globalThis.Error(\"Objects property names must be strings\",{cause:e})}function UN(e){return t=>{let n=new Map;for(let s=0;s{s=nn(s),i=nn(i);let a=e(s),c=e(i);return a!==c?a-c:n.get(s)-n.get(i)});return r.some((s,i)=>s!==t[i])?r:t}}var a6=UN(e=>{switch(e._tag){case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function c6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecJson??e.annotations?.toCodec;if(!un(n))return De(e,[bE]);let r=e.typeParameters.map(s=>Dl(nn(s))),o=n(r);return o===void 0?e:De(e,[Uc(o,t)])}case\"Unknown\":return De(e,[bE]);case\"ObjectKeyword\":return De(e,[s_]);case\"Undefined\":case\"Void\":case\"Literal\":case\"Number\":return e.toCodecJson();case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return FN(e),e.recur(t,Zm);case\"Union\":{let n=a6(e.types);return n!==e.types?new En(n,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}function NN(e){return j(DN(Jt(e.ast)))}var DN=It(e=>{let t=u6(e,DN);return t!==e&&e.context!==void 0?Gm(t,ww(e.context)):t});function u6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecIso??e.annotations?.toCodec;if(un(n)){let r=n(e.typeParameters.map(o=>Dl(o)));return De(e,[Uc(r,t)])}return e}case\"Arrays\":case\"Objects\":case\"Union\":case\"Suspend\":return e.recur(t)}return e}function uu(e){return j(jN(e.ast),{schema:e})}function f6(e){return j(BN(e.ast))}function l6(e,t){let n=Tc(e.ast)??fS(e.ast),r=JF(uu(e));return o=>r(o).pipe($(s=>d6(s,{rootName:n,...t})))}function d6(e,t){let n=t.rootName??\"root\",r=t.arrayItemName??\"item\",o=t.pretty??!0,s=t.indent??\" \",i=t.sortKeys??!0,a=new Set,c=[];return f(n,e,0),c.join(o?`\n`:\"\");function u(d,p){c.push(o?s.repeat(d)+p:p)}function f(d,p,m,g){let{attrs:b,safe:E}=tu.tagInfo(d,g);if(p===void 0)u(m,`<${E}${b}/>`);else if(typeof p==\"string\")u(m,`<${E}${b}>${tu.escapeText(p)}`);else if(typeof p!=\"object\"||p===null)u(m,`<${E}${b}>${tu.escapeText(N(p))}`);else{if(a.has(p))throw new globalThis.Error(\"Cycle detected while serializing to XML.\",{cause:p});a.add(p);try{if(globalThis.globalThis.Array.isArray(p)){if(p.length===0){u(m,`<${E}${b}/>`);return}u(m,`<${E}${b}>`);for(let h of p)f(r,h,m+1);u(m,``);return}let w=p,S=Object.keys(w);if(i&&S.sort(),S.length===0){u(m,`<${E}${b}/>`);return}u(m,`<${E}${b}>`);for(let h of S)f(tu.parseTagName(h).safe,w[h],m+1,h);u(m,``)}finally{a.delete(p)}}}}var tu={escapeText(e){return e.replace(/&/g,\"&\").replace(//g,\">\")},escapeAttribute(e){return e.replace(/&/g,\"&\").replace(/\"/g,\""\").replace(//g,\">\")},parseTagName(e){let t=e,n=e;return/^[A-Za-z_]/.test(n)||(n=\"_\"+n),n=n.replace(/[^A-Za-z0-9._-]/g,\"_\"),/^xml/i.test(n)&&(n=\"_\"+n),{safe:n,changed:n!==t}},tagInfo(e,t){let{changed:n,safe:r}=tu.parseTagName(e),s=n||t&&t!==e?` data-name=\"${tu.escapeAttribute(t??e)}\"`:\"\";return{safe:r,attrs:s}}},p6=UN(e=>{switch(e._tag){case\"Null\":case\"Boolean\":case\"Number\":case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function m6(e,t,n){switch(e._tag){case\"Declaration\":{let r=e.typeParameters.map(u=>j(t(nn(u)))),o=e.annotations?.toCodecStringTree;if(un(o)){let u=o(r);return u===void 0?e:De(e,[Uc(u,t)])}let s=e.annotations?.toCodecJson,i=un(s)?s(r):void 0,a=i===void 0?e.annotations?.toCodec:void 0,c=i??(un(a)?a(r):void 0);return c===void 0?n(e):De(e,[Uc(c,t)])}case\"Null\":return De(e,[h6]);case\"Boolean\":return De(e,[g6]);case\"Unknown\":case\"ObjectKeyword\":return De(e,[SE]);case\"Enum\":case\"Number\":case\"Literal\":case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return FN(e),e.recur(t,Zm);case\"Union\":{let r=p6(e.types);return r!==e.types?new En(r,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}var h6=new Je(new Fn(\"null\"),new Te(ce(()=>null),ce(()=>\"null\"))),g6=new Je(new En([new Fn(\"true\"),new Fn(\"false\")],\"anyOf\"),new Te(ce(e=>e===\"true\"),oa())),LN=new Te(ce(e=>typeof e==\"string\"?[e]:e),Sn()),$N=e=>e.transformation===LN,jN=fa(e=>{let t=m6(e,jN,n=>{throw new globalThis.Error(\"Missing structural codec for StringTree\",{cause:n})});return t!==e&&e.context!==void 0?Gm(t,ww(e.context)):t},{stopAt:$N}),UF=e=>dt(e)?Nc(Jr):Jr,BN=fa(e=>{let t=x6(e);if(eE(t)){let n=Dc(new En([new Kr(t.isMutable,t.elements.map(UF),t.rest.map(UF)),Oo],\"anyOf\"),t,LN);return dt(e)?Nc(n):n}return t},{stopAt:$N});function x6(e){return e._tag===\"Declaration\"||e._tag===\"Arrays\"||e._tag===\"Objects\"||e._tag===\"Union\"||e._tag===\"Suspend\"?e.recur(BN):e}var y6=ue(\"effect/schema/isGreaterThanDate\",J({exclusiveMinimum:er}),({annotations:e,payload:t})=>MU(t.exclusiveMinimum,e)),b6=ue(\"effect/schema/isGreaterThanOrEqualToDate\",J({minimum:er}),({annotations:e,payload:t})=>PU(t.minimum,e)),S6=ue(\"effect/schema/isLessThanDate\",J({exclusiveMaximum:er}),({annotations:e,payload:t})=>FU(t.exclusiveMaximum,e)),E6=ue(\"effect/schema/isLessThanOrEqualToDate\",J({maximum:er}),({annotations:e,payload:t})=>UU(t.maximum,e)),I6=ue(\"effect/schema/isBetweenDate\",J({minimum:er,maximum:er,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>NU(t,e)),w6=ue(\"effect/schema/isGreaterThanBigInt\",J({exclusiveMinimum:Fo}),({annotations:e,payload:t})=>DU(t.exclusiveMinimum,e)),T6=ue(\"effect/schema/isGreaterThanOrEqualToBigInt\",J({minimum:Fo}),({annotations:e,payload:t})=>LU(t.minimum,e)),v6=ue(\"effect/schema/isLessThanBigInt\",J({exclusiveMaximum:Fo}),({annotations:e,payload:t})=>$U(t.exclusiveMaximum,e)),A6=ue(\"effect/schema/isLessThanOrEqualToBigInt\",J({maximum:Fo}),({annotations:e,payload:t})=>jU(t.maximum,e)),C6=ue(\"effect/schema/isBetweenBigInt\",J({minimum:Fo,maximum:Fo,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>BU(t,e));function O6(e){let t=NN(e);return gF(MI(t),RI(t))}function k6(e){return Gh()}function R6(e){return Gh()}function _6(e,t){return n=>j(Ir(n.ast,{toCodecIso:()=>new Je(e.ast,_m(t))}),{schema:n})}function M6(e){let t=PN(e),n=MI(t),r=RI(t);return{empty:[],diff:(o,s)=>fF(n(o),n(s)),combine:(o,s)=>[...o,...s],patch:(o,s)=>{let i=n(o),a=lF(s,i);return Object.is(a,i)?o:r(a)}}}function P6(e){let t=nU(()=>n),n=st([e,Ye(t),Kl(X,t)]);return n}var id=j(Ir(Fc,{toCode:()=>({runtime:\"Schema.Json\",Type:\"Schema.Json\"})})),F6=Kl(X,id),U6=sn(\"effect/schema/Json\",id),N6=J({message:X,name:Ca(X),stack:Ca(X),cause:Ca(id)}),qN=j(Ir(o_,{toCode:()=>({runtime:\"Schema.MutableJson\",Type:\"Schema.MutableJson\"})})),D6=sn(\"effect/schema/MutableJson\",qN);function L6(e){return Co(e.ast)}function $6(e){return e.ast.context?.annotations}var An={};uo(An,{Array:()=>z8,Boolean:()=>x1,ConfigError:()=>hu,FalseValues:()=>g1,LogLevel:()=>b1,Port:()=>y1,Record:()=>q8,TrueValues:()=>h1,all:()=>f1,boolean:()=>X8,date:()=>sY,duration:()=>eY,fail:()=>W8,finite:()=>V8,int:()=>Z8,isConfig:()=>c1,literal:()=>Y8,literals:()=>Q8,logLevel:()=>nY,map:()=>u1,mapOrFail:()=>U8,nested:()=>iY,nonEmptyString:()=>K8,number:()=>G8,option:()=>$8,orElse:()=>N8,port:()=>tY,redacted:()=>rY,schema:()=>vn,string:()=>J8,succeed:()=>H8,unwrap:()=>d1,url:()=>oY,withDefault:()=>l1});var mu={};uo(mu,{ConfigProvider:()=>Pa,SourceError:()=>fg,constantCase:()=>m8,fromDir:()=>R8,fromDotEnv:()=>k8,fromDotEnvContents:()=>n1,fromEnv:()=>t1,fromEnvRecord:()=>Jw,fromUnknown:()=>y8,layer:()=>g8,layerAdd:()=>x8,make:()=>dg,makeArray:()=>Ww,makeRecord:()=>lg,makeValue:()=>pd,mapInput:()=>Hw,nested:()=>h8,orElse:()=>zw});var zN=\"~effect/platform/PlatformError\",fu=class extends yo(\"BadArgument\"){get message(){return`${this.module}.${this.method}${this.description?`: ${this.description}`:\"\"}`}};var WN=class extends yo(\"PlatformError\"){constructor(t){\"cause\"in t?super({reason:t,cause:t.cause}):super({reason:t})}[zN]=zN;get message(){return this.reason.message}};var B6=\"~effect/Stream\",q6={_R:_,_E:_,_A:_},z6={[B6]:q6,pipe(){return K(this,arguments)}},HN=e=>{let t=Object.create(z6);return t.channel=e,t};var H6=\"~effect/Sink\";var J6={_A:_,_In:_,_L:_,_E:_,_R:_},K6={[H6]:J6,pipe(){return K(this,arguments)}};var G6=e=>{let t=Object.create(K6);return t.transform=e,t},JN=e=>Ce((t,n)=>x(v(e.transform(t,n),G)));var KN=e=>G6(t=>{let n=[];if(e<=0)return x([n]);let r;return t.pipe(v(o=>{if(n.length+o.length<=e)return n.push(...o),n.length===e?G():te;for(let s=0;sx([n,r])))});var No={};uo(No,{DefaultChunkSize:()=>sD,Do:()=>_Z,TypeId:()=>oD,accumulate:()=>mZ,aggregate:()=>iZ,aggregateWithin:()=>Uw,bind:()=>PZ,bindEffect:()=>FZ,bindTo:()=>UZ,broadcast:()=>MD,broadcastN:()=>aZ,buffer:()=>gV,bufferArray:()=>xV,callback:()=>cD,catch:()=>ld,catchCause:()=>xD,catchCauseFilter:()=>TV,catchCauseIf:()=>wV,catchFilter:()=>SD,catchIf:()=>bD,catchReason:()=>EV,catchReasons:()=>IV,catchTag:()=>bV,catchTags:()=>SV,changes:()=>hZ,changesWith:()=>PD,changesWithEffect:()=>gZ,chunks:()=>CD,collect:()=>pZ,combine:()=>zV,combineArray:()=>WV,concat:()=>cd,cross:()=>V5,crossWith:()=>hD,debounce:()=>ZV,decodeText:()=>xZ,die:()=>u5,drain:()=>U5,drainFork:()=>N5,drop:()=>Fw,dropRight:()=>jV,dropUntil:()=>DV,dropUntilEffect:()=>LV,dropWhile:()=>vD,dropWhileEffect:()=>AD,dropWhileFilter:()=>$V,empty:()=>ai,encodeText:()=>yZ,ensuring:()=>AZ,fail:()=>ad,failCause:()=>uD,failCauseSync:()=>f5,failSync:()=>c5,filter:()=>cV,filterEffect:()=>fV,filterMap:()=>uV,filterMapEffect:()=>lV,flatMap:()=>pu,flatten:()=>kw,flattenArray:()=>F5,flattenEffect:()=>O5,flattenIterable:()=>q5,flattenTake:()=>z5,forever:()=>B5,fromArray:()=>ud,fromArrayEffect:()=>m5,fromArrays:()=>h5,fromAsyncIterable:()=>y5,fromChannel:()=>O,fromEffect:()=>ig,fromEffectDrain:()=>iD,fromEffectRepeat:()=>aD,fromEffectSchedule:()=>o5,fromEventListener:()=>E5,fromIterable:()=>_a,fromIterableEffect:()=>d5,fromIterableEffectRepeat:()=>p5,fromIteratorSucceed:()=>l5,fromPubSub:()=>g5,fromPubSubTake:()=>fD,fromPull:()=>io,fromQueue:()=>du,fromReadableStream:()=>x5,fromSchedule:()=>b5,fromSubscription:()=>S5,groupAdjacentBy:()=>oZ,groupBy:()=>nZ,groupByKey:()=>rZ,grouped:()=>eZ,groupedWithin:()=>tZ,haltWhen:()=>wZ,ignore:()=>OV,ignoreCause:()=>kV,interleave:()=>EZ,interleaveWith:()=>UD,interruptWhen:()=>IZ,intersperse:()=>FD,intersperseAffixes:()=>SZ,isStream:()=>Ie,iterate:()=>w5,let:()=>MZ,limitBytes:()=>PV,make:()=>i5,map:()=>nr,mapAccum:()=>HV,mapAccumArray:()=>Ma,mapAccumArrayEffect:()=>KV,mapAccumEffect:()=>JV,mapArray:()=>dD,mapArrayEffect:()=>k5,mapBoth:()=>C5,mapEffect:()=>fd,mapError:()=>ED,merge:()=>Rw,mergeAll:()=>G5,mergeEffect:()=>ag,mergeLeft:()=>J5,mergeResult:()=>H5,mergeRight:()=>K5,mkArrayBuffer:()=>JZ,mkString:()=>HZ,mkUint8Array:()=>KZ,never:()=>v5,onEnd:()=>vZ,onError:()=>TZ,onExit:()=>ND,onFirst:()=>LD,onStart:()=>DD,orDie:()=>CV,orElseIfEmpty:()=>vV,orElseSucceed:()=>AV,paginate:()=>I5,partition:()=>pV,partitionEffect:()=>dV,partitionQueue:()=>Pw,peel:()=>hV,pipeThrough:()=>dZ,pipeThroughChannel:()=>fZ,pipeThroughChannelOrFail:()=>lZ,prepend:()=>W5,provide:()=>$D,provideContext:()=>CZ,provideService:()=>OZ,provideServiceEffect:()=>Nw,race:()=>aV,raceAll:()=>gD,range:()=>T5,rechunk:()=>OD,repeat:()=>D5,repeatElements:()=>j5,result:()=>R5,retry:()=>RV,run:()=>BD,runCollect:()=>qD,runCount:()=>NZ,runDrain:()=>ug,runFold:()=>LZ,runFoldEffect:()=>$Z,runForEach:()=>qZ,runForEachArray:()=>zD,runForEachWhile:()=>zZ,runHead:()=>jZ,runIntoPubSub:()=>QZ,runIntoQueue:()=>t8,runLast:()=>BZ,runSum:()=>DZ,scan:()=>GV,scanEffect:()=>VV,schedule:()=>L5,scoped:()=>A5,service:()=>n5,serviceOption:()=>r5,share:()=>uZ,sliding:()=>BV,slidingSize:()=>kD,split:()=>qV,splitLines:()=>bZ,succeed:()=>lu,suspend:()=>tr,switchMap:()=>P5,sync:()=>a5,take:()=>MV,takeRight:()=>FV,takeUntil:()=>ID,takeUntilEffect:()=>wD,takeWhile:()=>TD,takeWhileEffect:()=>NV,takeWhileFilter:()=>UV,tap:()=>pD,tapBoth:()=>_5,tapCause:()=>yV,tapError:()=>yD,tapSink:()=>M5,throttle:()=>XV,throttleEffect:()=>RD,tick:()=>s5,timeout:()=>$5,timeoutOrElse:()=>mD,toAsyncIterable:()=>YZ,toAsyncIterableEffect:()=>ZZ,toAsyncIterableWith:()=>Lw,toChannel:()=>Or,toPubSub:()=>XZ,toPubSubTake:()=>WD,toPull:()=>WZ,toQueue:()=>e8,toReadableStream:()=>GZ,toReadableStreamEffect:()=>VZ,toReadableStreamWith:()=>Dw,transduce:()=>sZ,transformPull:()=>St,transformPullBracket:()=>Ow,unfold:()=>lD,unwrap:()=>ii,updateContext:()=>jD,updateService:()=>kZ,when:()=>mV,withExecutionPlan:()=>_V,withSpan:()=>RZ,zip:()=>Y5,zipFlatten:()=>eV,zipLatest:()=>sV,zipLatestAll:()=>Mw,zipLatestWith:()=>iV,zipLeft:()=>Q5,zipRight:()=>X5,zipWith:()=>_w,zipWithArray:()=>cg,zipWithIndex:()=>tV,zipWithNext:()=>nV,zipWithPrevious:()=>rV,zipWithPreviousAndNext:()=>oV});var VN=\"~effect/RcMap\",V6=e=>({[VN]:VN,lookup:e.lookup,context:e.context,scope:e.scope,idleTimeToLive:e.idleTimeToLive,capacity:e.capacity,state:{_tag:\"Open\",map:pl()},pipe(){return K(this,arguments)}}),ZN=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=V6({lookup:e.lookup,context:n,scope:r,idleTimeToLive:typeof e.idleTimeToLive==\"function\"?wd(e.idleTimeToLive,mt):Le(mt(e.idleTimeToLive??Ms)),capacity:Math.max(e.capacity??Number.POSITIVE_INFINITY,0)});return At(Eo(r,()=>{if(o.state._tag===\"Closed\")return te;let s=o.state.map;return o.state={_tag:\"Closed\"},qs(s,([,i])=>gr(Fe(i.scope,ut))).pipe(bn(()=>oe(()=>{Ym(s)})))}),o)}),Tw=l(2,(e,t)=>Hs(n=>{if(e.state._tag===\"Closed\")return vo;let r=e.state,o=eh(),s=Xs(r.map,t),i;if(s._tag===\"Some\")i=s.value,i.refCount++;else{if(Number.isFinite(e.capacity)&&c_(e.state.map)>=e.capacity)return P(new cb(`RcMap attempted to exceed capacity of ${e.capacity}`));{i={deferred:Bi(),scope:mr(),idleTimeToLive:e.idleTimeToLive(t),finalizer:void 0,fiber:void 0,expiresAt:0,refCount:1},i.finalizer=Z6(e,t,i),hl(r.map,t,i);let c=new Map(e.context.mapUnsafe);o.context.mapUnsafe.forEach((u,f)=>{c.set(f,u)}),c.set(xn.key,i.scope),e.lookup(t).pipe(Ao(ho(c)),la(i.scope)).addObserver(u=>lc(i.deferred,u))}}let a=Pr(o.context,xn);return Ht(a,i.finalizer).pipe(yn(n(qi(i.deferred))))}));var Z6=(e,t,n)=>Gn(r=>{if(n.refCount--,n.refCount>0)return te;if(e.state._tag===\"Closed\"||!a_(e.state.map,t)||Ku(n.idleTimeToLive))return e.state._tag===\"Open\"&&gl(e.state.map,t),Fe(n.scope,ut);if(!Xa(n.idleTimeToLive))return te;let o=r.getRef(Rf);return n.expiresAt=o.currentTimeMillisUnsafe()+_n(n.idleTimeToLive),n.fiber?te:(n.fiber=oS(function s(i){let a=o.currentTimeMillisUnsafe(),c=n.expiresAt-a;return c<=0?e.state._tag===\"Closed\"||n.refCount>0?te:(gl(e.state.map,t),i(Fe(n.scope,ut))):v(o.sleep(go(c)),()=>s(i))}).pipe(Xi(oe(()=>{n.fiber=void 0})),Ao(r.context),la(e.scope)),te)});var vw=l(2,(e,t)=>ea(n=>{if(e.state._tag===\"Closed\")return te;let r=Xs(e.state.map,t);if(r._tag===\"None\"||Ku(r.value.idleTimeToLive))return te;let o=r.value;return o.expiresAt=n.currentTimeMillisUnsafe()+_n(o.idleTimeToLive),te}));var Y6=\"~effect/RcRef\",Aw={_tag:\"Empty\"},Q6={_tag:\"Closed\"},X6={_A:_,_E:_},Cw=class{[Y6]=X6;pipe(){return K(this,arguments)}state=Aw;semaphore=Wc(1);acquire;context;scope;idleTimeToLive;constructor(t,n,r,o){this.acquire=t,this.context=n,this.scope=r,this.idleTimeToLive=o}},QN=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=new Cw(e.acquire,n,r,e.idleTimeToLive?mt(e.idleTimeToLive):void 0);return At(Eo(r,()=>{let s=o.state._tag===\"Acquired\"?Fe(o.state.scope,ut):te;return o.state=Q6,s}),o)}),e5=e=>Hs(function t(n){switch(e.state._tag){case\"Closed\":return vo;case\"Acquired\":return e.state.refCount++,e.state.fiber?At(ei(e.state.fiber),e.state):x(e.state);case\"Empty\":{let r=mr();return e.semaphore.withPermit(z(()=>e.state._tag!==\"Empty\"?t(n):n(xr(e.acquire,Pt(e.context,xn,r))).pipe($(o=>{let s={_tag:\"Acquired\",value:o,scope:r,fiber:void 0,refCount:1,invalidated:!1};return e.state=s,s}),yr(o=>os(o)?Fe(r,o):te))))}}}),XN=xe(function*(e){let t=e,n=yield*e5(t),r=yield*Yi,o=t.idleTimeToLive!==void 0&&Xa(t.idleTimeToLive);return yield*Eo(r,()=>(n.refCount--,n.refCount>0?te:t.idleTimeToLive===void 0?(t.state=Aw,Fe(n.scope,ut)):n.invalidated?Fe(n.scope,ut):o?(n.fiber=Gi(t.idleTimeToLive).pipe(v(()=>t.state._tag===\"Acquired\"&&t.state.refCount===0?(t.state=Aw,Fe(n.scope,ut)):te),Xi(oe(()=>{n.fiber=void 0})),Ao(t.context),la(t.scope)),te):te)),n.value});var eD=QN,tD=XN;var oD=\"~effect/Stream\",Ie=e=>M(e,oD),sD=vl,O=HN,ig=e=>O(Sh($(e,Ee))),n5=e=>ig(eS(e)),r5=e=>ig(tS(e)),iD=e=>io(x(v(e,()=>G()))),aD=e=>io(x($(e,Ee))),o5=(e,t)=>io(en(function*(){let n=yield*Br(t),r=yield*To(e,Ft,Ft.defaultValue()),o=!0,s=z(()=>n(r)).pipe(v(i=>To(e,Ft,i)),$(i=>(r=i,Ee(i))));return z(()=>o?(o=!1,x(Ee(r))):s)})),s5=e=>io(oe(()=>{let t=!0,n=x(Ee(void 0)),r=Gb(n,e);return z(()=>t?(t=!1,n):r)})),io=e=>O(Dt(e)),St=(e,t)=>O(Ce((n,r)=>v(Mo(e.channel,r),o=>t(o,r)))),Ow=(e,t)=>O(eo((n,r,o)=>v(Mo(e.channel,r),s=>t(s,r,o)))),Or=e=>e.channel,cD=(e,t)=>O(U_(e,t)),ai=O(Hc),lu=e=>O(bh(Ee(e))),i5=(...e)=>ud(e),a5=e=>O(D_(()=>Ee(e()))),tr=e=>O(yh(()=>e().channel)),ad=e=>O(_o(e)),c5=e=>O($_(e)),uD=e=>O(Al(e)),u5=e=>O(tI(e)),f5=e=>O(j_(e)),l5=(e,t)=>O(eI(()=>e,t)),_a=(e,t)=>Array.isArray(e)&&t?.chunkSize===void 0?ud(e):O(N_(e,t?.chunkSize)),d5=e=>ii($(e,_a)),p5=e=>pu(aD(e),_a),ud=e=>Me(e)?O(bh(e)):ai,m5=e=>ii($(e,ud)),h5=(...e)=>O(XE(ax(e,Me))),du=e=>O(q_(e)),g5=e=>O(z_(e)),fD=e=>O(W_(e)),x5=e=>O(H_(e)),y5=(e,t)=>O(J_(e,t)),b5=e=>io($(mc(e),t=>Ge($(t(void 0),Ee),()=>G()))),S5=e=>O(rI(e)),E5=(e,t,n)=>cD(r=>{function o(s){v_(r,s)}return Qi(oe(()=>e.addEventListener(t,o,n)),()=>oe(()=>e.removeEventListener(t,o,n)))},{bufferSize:typeof n==\"object\"?n.bufferSize:void 0}),lD=(e,t)=>io(oe(()=>{let n=e;return v(z(()=>t(n)),r=>r===void 0?G():(n=r[1],x(Ee(r[0]))))})),I5=(e,t)=>io(oe(()=>{let n=e,r=!1;return z(function o(){return r?G():v(t(n),([s,i])=>(ae(i)?r=!0:n=i.value,Me(s)?x(s):o()))})})),w5=(e,t)=>lD(e,n=>x([n,t(n)])),T5=(e,t,n=vl)=>e>t?ai:io(oe(()=>{let r=Math.max(1,n),o=e,s=!1;return z(()=>{if(s)return G();let i=t-o+1;if(i>r){let c=nx(o,o+r-1);return o+=r,x(c)}let a=nx(o,o+i-1);return s=!0,x(a)})})),v5=O(L_),ii=e=>O(xs($(e,Or))),A5=e=>O(EM(e.channel)),nr=l(2,(e,t)=>tr(()=>{let n=0;return O(to(e.channel,Mr(r=>t(r,n++))))})),C5=l(2,(e,t)=>e.pipe(nr(t.onSuccess),ED(t.onFailure))),dD=l(2,(e,t)=>O(to(e.channel,t))),fd=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,Eh(t,n),to(Ee),O)),O5=l(e=>Ie(e[0]),(e,t)=>fd(e,_,t)),k5=l(2,(e,t)=>O(Eh(e.channel,t))),R5=e=>e.pipe(nr(se),ld(t=>lu(re(t)))),pD=l(e=>Ie(e[0]),(e,t,n)=>fd(e,r=>At(t(r),r),n)),_5=l(2,(e,t)=>e.pipe(yD(t.onError),pD(t.onElement,{concurrency:t.concurrency}))),M5=l(2,(e,t)=>Ow(e,xe(function*(n,r,o){let s=Yn(),i=Yn(),a,c,u=!1,f=!1,d=s.whenOpen(z(()=>{if(a){let m=a;return a=void 0,f||s.closeUnsafe(),At(i.open,m)}return G()}));yield*z(()=>t.transform(d,o)).pipe(m=>rS(m,g=>(u=!0,os(g)&&(c=g.cause),i.open),!0),yt(o));let p=n.pipe(v(m=>(a=m,i.closeUnsafe(),s.openUnsafe(),At(i.await,m))),Ge(()=>(f=!0,i.closeUnsafe(),s.openUnsafe(),v(i.await,()=>G()))));return z(()=>c?tt(c):u?n:p)}))),pu=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,V_(r=>t(r).channel,n),O)),P5=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,yM(r=>t(r).channel,n),O)),kw=l(e=>Ie(e[0]),(e,t)=>pu(e,_,t)),F5=e=>O(rn(e.channel)),U5=e=>O(Q_(e.channel)),N5=l(2,(e,t)=>ag(e,ug(t))),D5=l(2,(e,t)=>O(X_(e.channel,t))),L5=l(2,(e,t)=>e.channel.pipe(rn,eM(t),to(Ee),O)),$5=l(2,(e,t)=>mD(e,{duration:t,orElse:()=>ai})),mD=l(2,(e,t)=>{let n=mt(t.duration);if(!Xa(n))return e;if(Ku(n))return tr(t.orElse);let r=Symbol();return xD(tr(()=>{let s=eh().getRef(Rf),i=_n(n),a,c=Yn(!1);return Rw(St(e,(u,f)=>z(()=>(a=s.currentTimeMillisUnsafe()+i,c.openUnsafe(),u)).pipe($(d=>(c.closeUnsafe(),a=void 0,d)),x)),iD(en(function*(){for(;;)if(yield*c.await,!(a===void 0||(yield*Gi(a-s.currentTimeMillisUnsafe()),a===void 0)||a-s.currentTimeMillisUnsafe()>0))return yield*hr(r)})),{haltStrategy:\"left\"})}),o=>o.reasons.find(i=>i._tag===\"Die\"&&i.defect===r)?t.orElse():uD(o))}),j5=l(2,(e,t)=>O(Ce((n,r)=>$(pe(rn(e.channel))(n,r),o=>{let s,i=en(function*(){let a=yield*o,c=Ee(a);return s=(yield*mc(t))(a).pipe(At(c),Ge(f=>(s=void 0,i))),c});return z(()=>s??i)})))),B5=e=>O(sI(e.channel)),q5=e=>pu(e,_a),z5=e=>e.channel.pipe(rn,Y_,O),cd=l(2,(e,t)=>kw(ud([e,t]))),W5=l(2,(e,t)=>cd(_a(t),e)),Rw=l(e=>Ie(e[0])&&Ie(e[1]),(e,t,n)=>O(vh(Or(e),Or(t),n))),ag=l(2,(e,t)=>e.channel.pipe(bM(t),O)),H5=l(2,(e,t)=>Rw(nr(e,se),nr(t,re))),J5=l(2,(e,t)=>ag(e,ug(t))),K5=l(2,(e,t)=>ag(t,ug(e))),G5=l(2,(e,t)=>kw(_a(e),t)),V5=l(2,(e,t)=>hD(e,t,(n,r)=>[n,r])),hD=l(3,(e,t,n)=>pu(e,r=>nr(t,o=>n(r,o)))),_w=l(3,(e,t,n)=>cg(e,t,Z5(n))),Z5=e=>(t,n)=>{let r=Math.min(t.length,n.length),o=[];for(let s=0;sO(eo(xe(function*(r,o){let s=yield*Mo(e.channel,o),i=yield*Mo(t.channel,o),a=en(function*(){let f=yield*yt(s,o),d=yield*yt(i,o);return yield*m_([f,d])}),c={_tag:\"PullBoth\"};return en(function*(){let[f,d]=c._tag===\"PullBoth\"?yield*a:c._tag===\"PullLeft\"?[yield*s,c.rightArray]:[c.leftArray,yield*i],p=n(f,d);return Me(p[1])?c={_tag:\"PullRight\",leftArray:p[1]}:Me(p[2])?c={_tag:\"PullLeft\",rightArray:p[2]}:c={_tag:\"PullBoth\"},p[0]})})))),Y5=l(2,(e,t)=>_w(e,t,(n,r)=>[n,r])),Q5=l(2,(e,t)=>cg(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=n.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),X5=l(2,(e,t)=>cg(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=r.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),eV=l(2,(e,t)=>_w(e,t,(n,r)=>[...n,r])),tV=e=>nr(e,(t,n)=>[t,n]),nV=e=>Ma(e,R,(t,n)=>{let r=0;t._tag===\"None\"&&(r=1,t=k(n[0]));let o=gt();for(;rMa(e,R,(t,n)=>{let r=gt();for(let o=0;oMa(e,()=>({prev:R(),current:R()}),(t,n)=>{let r=0,o;t.current._tag===\"None\"?(r=1,o=n[0],t.current=k(o)):o=t.current.value;let s=gt();for(;rO(yh(()=>{let t=[],n=new Set,r=Yn();return Th(XE(e.map((o,s)=>o.channel.pipe(rn,Eh(i=>(t[s]=i,n.has(s)?x(Ee(t.slice())):(n.add(s),n.sizeMw(e,t)),iV=l(3,(e,t,n)=>nr(Mw(e,t),([r,o])=>n(r,o))),gD=(...e)=>O(Ce((t,n)=>oe(()=>{let r,o=Vb(e.map(s=>{let i=vt(n);return Mo(s.channel,i).pipe(v(a=>Jb(x(a),a)),yr(a=>a._tag===\"Success\"?r?Fe(i,a):(r=a.value[0],te):Fe(i,a)),$(([,a])=>a))}));return z(()=>r??o)}))),aV=l(2,(e,t)=>gD(e,t)),cV=l(2,(e,t)=>O(nM(Or(e),t))),uV=l(2,(e,t)=>O(rM(Or(e),t))),fV=l(2,(e,t)=>O(oM(Or(e),t))),lV=l(2,(e,t)=>O(sM(Or(e),t))),Pw=l(e=>Ie(e[0]),xe(function*(e,t,n){let r=yield*Yi,o=yield*Mo(e.channel,r),s=n?.capacity===\"unbounded\"?void 0:n?.capacity??sD,i=yield*wr({capacity:s}),a=yield*wr({capacity:s});return yield*en(function*(){for(;;){let c=yield*o,u=[],f=[];for(let p=0;p0){let p=xh(i,f);p.length>0&&(d=yield*Ec(ma(i,p)))}if(u.length>0){let p=xh(a,u);p.length>0&&(yield*ma(a,p))}d&&(yield*$c(d))}}).pipe(zr(c=>(ti(i,c),ti(a,c),te)),yt(r)),[i,a]})),dV=l(e=>Ie(e[0]),(e,t,n)=>$(Pw(fd(e,r=>t(r),n),r=>r,n),([r,o])=>[du(r),du(o)])),pV=l(e=>Ie(e[0]),(e,t,n)=>$(Pw(e,t,{capacity:n?.bufferSize??16}),([r,o])=>[du(o),du(r)])),mV=l(2,(e,t)=>t.pipe($(n=>n?e:ai),ii)),hV=l(2,xe(function*(e,t){let n,r=yield*dI(e.channel),o=ft(r,a=>(n=a,tt(a))),s=io(x(o)),i=yield*BD(s,t);return n?[i,ai]:(s=io(x(r)),[i,s])})),gV=l(2,(e,t)=>O(wM(e.channel,t))),xV=l(2,(e,t)=>O(IM(e.channel,t))),xD=l(2,(e,t)=>e.channel.pipe(Kc(n=>t(n).channel),O)),yV=l(2,(e,t)=>e.channel.pipe(wh(t),O)),ld=l(2,(e,t)=>O(Qr(e.channel,n=>t(n).channel)));var yD=l(2,(e,t)=>e.channel.pipe(cM(t),O)),bD=l(e=>Ie(e[0]),(e,t,n,r)=>O(uM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),SD=l(e=>Ie(e[0]),(e,t,n,r)=>O(fM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),bV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Array.isArray(t)?s=>M(s,\"_tag\")&&t.includes(s._tag):$t(t);return bD(e,o,n,r)}),SV=l(e=>Ie(e[0]),(e,t,n)=>{let r;return SD(e,o=>(r??=Object.keys(t),M(o,\"_tag\")&&Qp(o._tag)&&r.includes(o._tag)?se(o):re(o)),o=>t[o._tag](o),n)}),EV=l(e=>Ie(e[0]),(e,t,n,r,o)=>O(lM(Or(e),t,n,(s,i)=>r(s,i).channel,o&&((s,i)=>o(s,i).channel)))),IV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Object.create(null);for(let i of Object.keys(n)){let a=n[i];o[i]=(c,u)=>a(c,u).channel}let s=r&&((i,a)=>r(i,a).channel);return O(dM(e.channel,t,o,s))}),ED=l(2,(e,t)=>O(pM(e.channel,t))),wV=l(3,(e,t,n)=>O(aM(e.channel,t,r=>n(r).channel))),TV=l(3,(e,t,n)=>O(iI(e.channel,t,(r,o)=>n(r,o).channel))),vV=l(2,(e,t)=>O(Z_(e.channel,n=>Or(t())))),AV=l(2,(e,t)=>ld(e,n=>lu(t(n)))),CV=e=>O(mM(e.channel)),OV=l(e=>Ie(e[0]),(e,t)=>O(hM(e.channel,t))),kV=l(e=>Ie(e[0]),(e,t)=>O(gM(e.channel,t))),RV=l(2,(e,t)=>O(xM(e.channel,t))),rD=(e,t)=>ii($(Br(t),n=>{let r=Ft.defaultValue(),o=()=>ld(Nw(e,Ft,oe(()=>r)),s=>ii(Ge($(n(s),i=>(r=i,ii(At(Bf,o())))),()=>x(ad(s)))));return o()})),_V=l(e=>Ie(e[0]),(e,t,n)=>tr(()=>{let r=n?.preventFallbackOnPartialStream??!1,o=0,s={attempt:0,stepIndex:0},i=Nw(Gp,oe(()=>(s={attempt:s.attempt+1,stepIndex:o},s))),a=n?.onEvent===void 0?void 0:jb(n.onEvent,()=>s),c,u=a===void 0?_:p=>ND(DD(p,$(a.begin,m=>{c=m})),m=>z(()=>{if(c===void 0)return te;let g=c;return c=void 0,a.end(g,m)})),f=R(),d=tr(()=>{let p=t.steps[o];if(!p)return ad(Xg(f));let m=i(u($D(e,p.provide))),g=!1;if(_e(f)){let b=f.value,E=!1,w=m;m=tr(()=>E?w:(E=!0,ad(b))),m=rD(m,jf(p,!1))}else{let b=jf(p,!0);m=b?rD(m,b):m}return ld(r?LD(m,b=>(g=!0,te)):m,b=>(o++,r&&g?ad(b):(f=k(b),d)))});return d})),MV=l(2,(e,t)=>t<1?ai:ID(e,(n,r)=>r===t-1)),PV=l(3,(e,t,n)=>tr(()=>{let r=BigInt(t),o=BigInt(0),s=!1;return cd(TD(e,i=>{let a=o+BigInt(i.length);return a>r?(s=!0,!1):(o=a,!0)}),tr(()=>s?n():ai))})),FV=l(2,(e,t)=>Ma(e,hs,(n,r)=>(nh(n,r),n.length>t&&El(n,n.length-t),[n,Ss]),{onHalt(n){return pa(n)}})),ID=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return v(z(()=>i?G():r),c=>{let u=c.findIndex(f=>t(f,s++));if(u>=0){i=!0;let f=c.slice(0,n?.excludeLast?u:u+1);return Me(f)?x(f):G()}return x(c)})}))),wD=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return en(function*(){if(i)return yield*G();let a=yield*r;for(let c=0;cSt(e,(n,r)=>oe(()=>{let o=0,s=!1,i=v(z(()=>s?G():n),a=>{let c=[];for(let u=0;uSt(e,(n,r)=>oe(()=>{let o=!1,s=v(z(()=>o?G():n),i=>{let a=[];for(let c=0;cwD(e,(n,r)=>$(t(n,r),o=>!o),{excludeLast:!0})),Fw=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=0,s=n.pipe(v(i=>o>=t?x(i):(o+=i.length,o<=t?s:x(i.slice(t-o)))));return s}))),DV=l(2,(e,t)=>Fw(vD(e,(n,r)=>!t(n,r)),1)),LV=l(2,(e,t)=>Fw(AD(e,(n,r)=>$(t(n,r),o=>!o)),1)),vD=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=v(n,a=>{let c=a.findIndex(u=>!t(u,s++));return c===-1?i:(o=!1,x(a.slice(c)))});return z(()=>o?i:n)}))),$V=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=v(n,i=>{let a=i.findIndex(c=>ie(t(c)));return a===-1?s:(o=!1,x(i.slice(a)))});return z(()=>o?s:n)}))),AD=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=en(function*(){for(;;){let a=yield*n;for(let c=0;co?i:n)}))),jV=l(2,(e,t)=>t<=0?e:St(e,(n,r)=>oe(()=>{let o=hs(),s=v(n,i=>{rh(o,i);let a=o.length-t,c=da(o,a);return qt(c)?x(c):s});return s}))),CD=e=>e.channel.pipe(to(Ee),O),OD=l(2,(e,t)=>(t=Math.max(1,t),St(e,(n,r)=>oe(()=>{let o=gt(),s=0,i,a=!1;return z(function c(){if(a)return G();if(i===void 0)return v(n,u=>o.length===0&&u.length===t?x(u):o.length+u.length{if(o.length===0)return G();let c=o;return a=!0,o=[],x(c)}))})))),BV=l(2,(e,t)=>kD(e,t,1)),kD=l(3,(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=null,i=hs(),a=!1,c=0,u=Vi(r,{onSuccess(f){if(rh(i,f),c>0){let p=i.length;El(i,c),c=Math.max(0,c-p)}if(i.length=t;)if(t===n)d.push(da(i,t));else if(d.push(b_(i,t)),t===1&&n<=0)Zr(i);else{let p=i.length;El(i,n),c=Math.max(0,n-p)}return x(d)},onFailure(f){return a&&El(i,t-n),i.length===0?tt(f):(s=f,x(Ee(pa(i))))}});return z(()=>s?tt(s):u)}))),qV=l(2,(e,t)=>Ma(e,gt,(n,r)=>{let o=gt();for(let s=0;soI(rn(e.channel),rn(t.channel),n,r).pipe(to(Ee),O)),WV=l(4,(e,t,n,r)=>O(oI(e.channel,t.channel,n,r))),HV=l(e=>Ie(e[0]),(e,t,n,r)=>O(Jc(e.channel,t,(o,s)=>{let i=gt();for(let a=0;aIe(e[0]),(e,t,n,r)=>O(Jc(e.channel,t,(o,s)=>{let[i,a]=n(o,s);return o=i,[o,Me(a)?Ee(a):Ss]},r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0))),Ss=gt(),JV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(rn,Jc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):gt()]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),KV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(Jc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):Ss]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),GV=l(3,(e,t,n)=>tr(()=>{let r=!0;return O(Jc(e.channel,Le(t),(o,s)=>{let i=gt();r&&(r=!1,i.push(o));for(let a=0;ae.channel.pipe(rn,iM(t,n),to(Ee),O)),ZV=l(2,(e,t)=>St(e,xe(function*(n,r){let o=yield*Rf,s=_n(mt(t)),i,a,c=1/0,u=Yn(),f=Yn(),d=Yn();yield*n.pipe(u.whenOpen,v(m=>(f.openUnsafe(),i=m,c=o.currentTimeMillisUnsafe()+s,te)),Ct({disableYield:!0}),zr(m=>(a=m,c=o.currentTimeMillisUnsafe(),f.openUnsafe(),d.openUnsafe(),te)),yt(r));let p=z(function m(){let g=o.currentTimeMillisUnsafe(),b=c{if(o.currentTimeMillisUnsafe(){if(a){if(i){let m=x(Ee(Gd(i)));return i=void 0,m}return tt(a)}return u.openUnsafe(),f.whenOpen(p)})}))),RD=l(2,(e,t)=>{let n=t.burst??0;return t.strategy===\"enforce\"?YV(e,t.cost,t.units,t.duration,n):QV(e,t.cost,t.units,t.duration,n)}),YV=(e,t,n,r,o)=>St(e,s=>ea(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(v(s,function d(p){return v(t(p),m=>{let g=i.currentTimeMillisUnsafe(),E=(g-f)/a,w=u+E*n,S=w<0?c:Math.min(w,c);return m<=S?(u=S-m,f=g,x(p)):v(s,d)})}))})),QV=(e,t,n,r,o)=>St(e,s=>ea(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(v(s,d=>v(t(d),p=>{let m=i.currentTimeMillisUnsafe(),b=(m-f)/a,E=u+b*n,S=(E<0?c:Math.min(E,c))-p;if(S>=0)return u=S,f=m,x(d);let h=-S/n,T=Math.max(0,h*a);return T>0?v(Gi(T),()=>(u=S,f=m,x(d))):(u=S,f=m,x(d))})))})),XV=l(2,(e,t)=>RD(e,{...t,cost:n=>x(t.cost(n))})),eZ=l(2,(e,t)=>CD(OD(e,t))),tZ=l(3,(e,t,n)=>Uw(e,KN(t),Db(n))),nZ=l(e=>Ie(e[0]),(e,t,n)=>_D(e,xe(function*(r,o,s){for(let i=0;iIe(e[0]),(e,t,n)=>tr(()=>{let r=pl();return _D(e,xe(function*(o,s,i){for(let a=0;aOw(e,xe(function*(r,o,s){let i=yield*T_();yield*Ht(o,Yr(i));let a=pl(),c=yield*ZN({lookup:u=>Qi(wr({capacity:n?.bufferSize??4096}).pipe(bn(f=>(hl(a,u,f),Qn(i,[u,du(f)])))),f=>(gl(a,u),A_(f))),idleTimeToLive:n?.idleTimeToLive??Fr}).pipe(ss(s));return yield*qr({while:cn,body:Le(v(r,u=>t(u,c,a))),step:Rr}).pipe(ft(u=>Nt(i,u)),yt(o)),zc(i)})),oZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=gt(),a=n.pipe(v(u=>{for(let f=0;fc?G():a),()=>{c=!0;let u=s;return s=void 0,u&&qt(u)?x(Ee([o,u])):G()})}))),sZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=z(()=>{if(s!==void 0){let c=s;return s=void 0,x(c)}return n}).pipe(Ws(c=>(o=rs(c),G()))),a=$(z(()=>t.transform(i,r)),([c,u])=>(s=u,Ee(c)));return z(()=>o||a)}))),iZ=l(2,(e,t)=>Uw(e,t,Nf)),Uw=l(3,(e,t,n)=>O(eo(xe(function*(r,o,s){let i=yield*Mo(e.channel,o),a=Yn(!1),c=Symbol(),u=yield*wr({capacity:0});yield*i.pipe(a.whenOpen,v(h=>(a.closeUnsafe(),Qn(u,h))),Ct,ft(h=>Nt(u,h)),yt(s));let f=R(),d,p=!1,m=yield*mc(n),b=z(function h(){return v(m(f),()=>p?Qn(u,c):h())}).pipe(v(()=>Ki),Ge(()=>G())),E=gs(u).pipe(v(h=>h===c?G():(p=!0,x(h)))),w=z(()=>{if(d!==void 0){let h=d;return d=void 0,p=!0,x(h)}return a.openUnsafe(),E}),S=v(([h,T])=>!p&&u.state._tag===\"Done\"?G():(f=k(h),d=T,x(Ee(h))));return z(()=>u.state._tag===\"Done\"&&d===void 0?u.state.exit:(p=d!==void 0,x(z(()=>t.transform(w,s))))).pipe(v(h=>bc(S(h),b)))})))),aZ=l(2,xe(function*(e,t){let n=yield*cZ(t),r=new Array(t.n),o=yield*xn;for(let s=0;sFe(i,c)),O)}return yield*ga(e.channel,s=>Bc(n,s)).pipe(yr(s=>Bc(n,s)),Ic),r})),cZ=e=>Qi(e.capacity===\"unbounded\"?lh(e):e.strategy===\"dropping\"?uh(e):e.strategy===\"sliding\"?fh(e):ch(e),Il),MD=l(2,(e,t)=>$(WD(e,t),fD)),uZ=l(2,(e,t)=>$(eD({acquire:MD(e,t),idleTimeToLive:t.idleTimeToLive}),n=>ii(tD(n)))),fZ=l(2,(e,t)=>O(aI(e.channel,t))),lZ=l(2,(e,t)=>O(cI(e.channel,t))),dZ=l(2,(e,t)=>e.channel.pipe(cI(JN(t)),Ih(([n,r])=>r?bh(r):Hc),O)),pZ=e=>ig(qD(e)),mZ=e=>Ma(e,gt,(t,n)=>{let r=ox(t,n);return[r,[r]]}),hZ=e=>PD(e,B),PD=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s;return v(n,function i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));uSt(e,(n,r)=>oe(()=>{let o=!0,s;return v(n,xe(function*i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));uIe(e[0]),(e,t)=>tr(()=>{let n=new TextDecoder(t?.encoding);return nr(e,r=>n.decode(r,{stream:!0}))})),yZ=e=>tr(()=>{let t=new TextEncoder;return nr(e,n=>t.encode(n))}),bZ=e=>e.channel.pipe(aI(SM()),O),FD=l(2,(e,t)=>dD(e,(n,r)=>{let o=r===0?[]:[t],s=n.length-1;for(let i=0;ilu(t.start).pipe(cd(FD(e,t.middle)),cd(lu(t.end)))),EZ=l(2,(e,t)=>UD(e,t,_a(tT([!0,!1])))),UD=l(3,(e,t,n)=>O(Ce(xe(function*(r,o){let s=yield*pe(rn(n.channel))(r,o),i=Symbol(),a=!1,c=!1,u=(yield*pe(rn(e.channel))(r,o)).pipe(Ge(()=>(a=!0,x(i)))),f=(yield*pe(rn(t.channel))(r,o)).pipe(Ge(()=>(c=!0,x(i))));return en(function*(){for(;;){if(a&&c)return yield*G();let d=yield*s;if(d&&a||!d&&c)continue;let p=yield*d?u:f;if(p!==i)return Ee(p)}})})))),IZ=l(2,(e,t)=>O(TM(e.channel,t))),wZ=l(2,(e,t)=>O(vM(e.channel,t))),ND=l(2,(e,t)=>O(Cl(e.channel,t))),TZ=l(2,(e,t)=>O(AM(e.channel,t))),DD=l(2,(e,t)=>O(CM(e.channel,t))),LD=l(2,(e,t)=>O(uI(e.channel,n=>t(n[0])))),vZ=l(2,(e,t)=>O(OM(e.channel,t))),AZ=l(2,(e,t)=>O(kM(e.channel,t))),$D=l(e=>Ie(e[0]),(e,t,n)=>O(RM(e.channel,t,n))),CZ=l(2,(e,t)=>O(Ah(e.channel,t))),OZ=l(3,(e,t,n)=>O(Ch(e.channel,t,n))),Nw=l(3,(e,t,n)=>O(Oh(e.channel,t,n))),jD=l(2,(e,t)=>O(_M(e.channel,t))),kZ=l(3,(e,t,n)=>jD(e,r=>Pt(r,t,n(Xe(r,t))))),RZ=function(){let e=Ie(arguments[0]),t=e?arguments[1]:arguments[0],n=Fs(e?arguments[2]:arguments[1]);if(e){let r=arguments[0];return O(fI(r.channel,t,n))}return r=>O(fI(r.channel,t,n))},_Z=lu({}),MZ=l(3,(e,t,n)=>nr(e,r=>({...r,[t]:n(r)})));var PZ=l(e=>Ie(e[0]),(e,t,n,r)=>pu(e,o=>nr(n(o),s=>({...o,[t]:s})),r)),FZ=l(e=>Ie(e[0]),(e,t,n,r)=>fd(e,o=>$(n(o),s=>({...o,[t]:s})),r)),UZ=l(2,(e,t)=>nr(e,n=>({[t]:n}))),BD=l(2,(e,t)=>nS(n=>Mo(e.channel,n).pipe(v(r=>t.transform(r,n)),$(([r])=>r)))),qD=e=>xa(e.channel,()=>[],(t,n)=>{for(let r=0;rxa(e.channel,()=>0,(t,n)=>t+n.length),DZ=e=>xa(e.channel,()=>0,(t,n)=>{for(let r=0;rxa(e.channel,t,(r,o)=>{for(let s=0;sNM(e.channel,t,(r,o)=>{let s=0,i=r;return $(qr({while:()=>sn(i,o[s]),step(a){i=a,s++}}),()=>i)})),jZ=e=>$(FM(e.channel),Bt(aT(0))),BZ=e=>$(UM(e.channel),Bt(Gd)),qZ=l(2,(e,t)=>ga(e.channel,n=>{let r=0;return qr({while:()=>rt(n[r++]),step:Rr})})),zZ=l(2,(e,t)=>PM(e.channel,n=>{let r=!1,o=0;return $(qr({while:()=>!r&&ot(n[o]),step(s){o++,s||(r=!0)}}),()=>!r)})),zD=l(2,(e,t)=>ga(e.channel,t)),ug=e=>MM(e.channel),WZ=e=>dI(e.channel),HZ=e=>xa(e.channel,()=>\"\",(t,n)=>t+n.join(\"\")),JZ=e=>$(lI(e.channel),t=>t.buffer),KZ=e=>lI(e.channel),Dw=l(e=>Ie(e[0]),(e,t,n)=>{let r,o,s=Yn(!1);return new ReadableStream({start(i){o=Wf(xr(zD(e,a=>s.whenOpen(oe(()=>{s.closeUnsafe();for(let c=0;c{a._tag===\"Failure\"?i.error(zp(a.cause)):i.close()})},pull(){return new Promise(i=>{r=i,s.openUnsafe()})},cancel(){if(o)return iS(zs(ei(o)))}},n?.strategy)}),GZ=l(e=>Ie(e[0]),(e,t)=>Dw(e,pn(),t)),VZ=l(e=>Ie(e[0]),(e,t)=>$(Zi(),n=>Dw(e,n,t))),Lw=l(2,(e,t)=>({[Symbol.asyncIterator](){let n=aS(t),r=Ao(t),o=mr(),s,i,a,c,u=d=>{if(c)return c;let p=a;return c=n(At(yn(p?ei(p):te,Fe(o,d)),{done:!0,value:void 0})),c},f=async d=>{try{await u(d)}catch(p){await n(cS(\"Suppressed error while closing Stream async iterator\",p))}};return{async next(){if(c)return c;if(i){let m=i.next();if(!m.done)return m;i=void 0}let d=r(s??v(Mo(e.channel,o),m=>(s=m,m)));a=d;let p=await n(p_(d));if(a===d&&(a=void 0),et(p))return i=p.value[Symbol.iterator](),i.next();if(is(p.cause))return u(ut);if(c&&rb(p.cause))return c;throw await f(p),zp(p.cause)},return(){return u(ut)},async throw(d){throw await f(ub(d)),d}}}})),ZZ=e=>$(Zi(),t=>Lw(e,t)),YZ=e=>Lw(e,pn()),QZ=l(e=>Ie(e[0]),(e,t,n)=>mI(e.channel,t,n)),XZ=l(2,(e,t)=>$M(e.channel,t)),WD=l(2,(e,t)=>jM(e.channel,t)),e8=l(2,(e,t)=>DM(e.channel,t)),t8=l(2,(e,t)=>pI(e.channel,t));var dd=BigInt(1024),Cne=dd*dd*dd*dd*dd;var $w=Vt(\"effect/platform/FileSystem\");var JD=\"~effect/platform/Path\",Bw=Vt(\"effect/Path\");function KD(e,t){let n=\"\",r=0,o=-1,s=0,i;for(let a=0;a<=e.length;++a){if(a2){let c=n.lastIndexOf(\"/\");if(c!==n.length-1){c===-1?(n=\"\",r=0):(n=n.slice(0,c),r=n.length-1-n.lastIndexOf(\"/\")),o=a,s=0;continue}}else if(n.length===2||n.length===1){n=\"\",r=0,o=a,s=0;continue}}t&&(n.length>0?n+=\"/..\":n=\"..\",r=2)}else n.length>0?n+=\"/\"+e.slice(o+1,a):n=e.slice(o+1,a),r=a-o-1;o=a,s=0}else i===46&&s!==-1?++s:s=-1}return n}function n8(e,t){let n=t.dir||t.root,r=t.base||(t.name||\"\")+(t.ext||\"\");return n?n===t.root?n+r:n+e+r:r}function r8(e){if(e.protocol!==\"file:\")return P(new fu({module:\"Path\",method:\"fromFileUrl\",description:\"URL must be of scheme file\"}));if(e.hostname!==\"\")return P(new fu({module:\"Path\",method:\"fromFileUrl\",description:\"Invalid file URL host\"}));let t=e.pathname;for(let n=0;n=-1&&!n;o--){let s;if(o>=0)s=arguments[o];else{let i=globalThis.process;r===void 0&&\"process\"in globalThis&&typeof i==\"object\"&&i!==null&&typeof i.cwd==\"function\"&&(r=i.cwd()),s=r}s.length!==0&&(t=s+\"/\"+t,n=s.charCodeAt(0)===47)}return t=KD(t,!n),n?t.length>0?\"/\"+t:\"/\":t.length>0?t:\".\"},o8=47;function s8(e){let t=new URL(\"file://\"),n=GD(e);return e.charCodeAt(e.length-1)===o8&&n[n.length-1]!==\"/\"&&(n+=\"/\"),t.pathname=l8(n),x(t)}var i8=/%/g,a8=/\\\\/g,c8=/\\n/g,u8=/\\r/g,f8=/\\t/g;function l8(e){return e.includes(\"%\")&&(e=e.replace(i8,\"%25\")),e.includes(\"\\\\\")&&(e=e.replace(a8,\"%5C\")),e.includes(`\n`)&&(e=e.replace(c8,\"%0A\")),e.includes(\"\\r\")&&(e=e.replace(u8,\"%0D\")),e.includes(\"\t\")&&(e=e.replace(f8,\"%09\")),e}var jw=Bw.of({[JD]:JD,resolve:GD,normalize(e){if(e.length===0)return\".\";let t=e.charCodeAt(0)===47,n=e.charCodeAt(e.length-1)===47;return e=KD(e,!t),e.length===0&&!t&&(e=\".\"),e.length>0&&n&&(e+=\"/\"),t?\"/\"+e:e},isAbsolute(e){return e.length>0&&e.charCodeAt(0)===47},join(){if(arguments.length===0)return\".\";let e;for(let t=0;t0&&(e===void 0?e=n:e+=\"/\"+n)}return e===void 0?\".\":jw.normalize(e)},relative(e,t){if(e===t||(e=jw.resolve(e),t=jw.resolve(t),e===t))return\"\";let n=1;for(;nc){if(t.charCodeAt(s+f)===47)return t.slice(s+f+1);if(f===0)return t.slice(s+f)}else o>c&&(e.charCodeAt(n+f)===47?u=f:f===0&&(u=0));break}let p=e.charCodeAt(n+f),m=t.charCodeAt(s+f);if(p!==m)break;p===47&&(u=f)}let d=\"\";for(f=n+u+1;f<=r;++f)(f===r||e.charCodeAt(f)===47)&&(d.length===0?d+=\"..\":d+=\"/..\");return d.length>0?d+t.slice(s+u):(s+=u,t.charCodeAt(s)===47&&++s,t.slice(s))},dirname(e){if(e.length===0)return\".\";let t=e.charCodeAt(0),n=t===47,r=-1,o=!0;for(let s=e.length-1;s>=1;--s)if(t=e.charCodeAt(s),t===47){if(!o){r=s;break}}else o=!1;return r===-1?n?\"/\":\".\":n&&r===1?\"//\":e.slice(0,r)},basename(e,t){let n=0,r=-1,o=!0,s;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return\"\";let i=t.length-1,a=-1;for(s=e.length-1;s>=0;--s){let c=e.charCodeAt(s);if(c===47){if(!o){n=s+1;break}}else a===-1&&(o=!1,a=s+1),i>=0&&(c===t.charCodeAt(i)?--i===-1&&(r=s):(i=-1,r=a))}return n===r?r=a:r===-1&&(r=e.length),e.slice(n,r)}else{for(s=e.length-1;s>=0;--s)if(e.charCodeAt(s)===47){if(!o){n=s+1;break}}else r===-1&&(o=!1,r=s+1);return r===-1?\"\":e.slice(n,r)}},extname(e){let t=-1,n=0,r=-1,o=!0,s=0;for(let i=e.length-1;i>=0;--i){let a=e.charCodeAt(i);if(a===47){if(!o){n=i+1;break}continue}r===-1&&(o=!1,r=i+1),a===46?t===-1?t=i:s!==1&&(s=1):t!==-1&&(s=-1)}return t===-1||r===-1||s===0||s===1&&t===r-1&&t===n+1?\"\":e.slice(t,r)},format:function(t){if(t===null||typeof t!=\"object\")throw new TypeError('The \"pathObject\" argument must be of type Object. Received type '+typeof t);return n8(\"/\",t)},parse(e){let t={root:\"\",dir:\"\",base:\"\",ext:\"\",name:\"\"};if(e.length===0)return t;let n=e.charCodeAt(0),r=n===47,o;r?(t.root=\"/\",o=1):o=0;let s=-1,i=0,a=-1,c=!0,u=e.length-1,f=0;for(;u>=o;--u){if(n=e.charCodeAt(u),n===47){if(!c){i=u+1;break}continue}a===-1&&(c=!1,a=u+1),n===46?s===-1?s=u:f!==1&&(f=1):s!==-1&&(f=-1)}return s===-1||a===-1||f===0||f===1&&s===a-1&&s===i+1?a!==-1&&(i===0&&r?t.base=t.name=e.slice(1,a):t.base=t.name=e.slice(i,a)):(i===0&&r?(t.name=e.slice(1,s),t.base=e.slice(1,a)):(t.name=e.slice(i,s),t.base=e.slice(i,a)),t.ext=e.slice(s,a)),i>0?t.dir=e.slice(0,i-1):r&&(t.dir=\"/\"),t},sep:\"/\",fromFileUrl:r8,toFileUrl:s8,toNamespacedPath:_});function pd(e){return{_tag:\"Value\",value:e}}function lg(e,t){return{_tag:\"Record\",keys:e,value:t}}function Ww(e,t){return{_tag:\"Array\",length:e,value:t}}var fg=class extends yo(\"SourceError\"){},Pa=Ae(\"effect/ConfigProvider\",{defaultValue:()=>t1()}),d8={...ln,toJSON(){return{_id:\"ConfigProvider\"}}},p8=e=>e;function ZD(e,t){let n=Object.create(d8);return n.load=e,n.mapInput=t,n}function YD(e,t){return ZD(n=>e(t(n)),n=>YD(e,wd(t,n)))}function QD(e,t){return ZD(n=>v(e.load(n),r=>r!==void 0?x(r):t.load(n)),n=>QD(e.mapInput(n),t.mapInput(n)))}function dg(e){return YD(e,p8)}var zw=l(2,(e,t)=>QD(e,t)),Hw=l(2,(e,t)=>e.mapInput(t)),m8=Hw(e=>e.map(t=>typeof t==\"number\"?t:PO(t))),h8=l(2,(e,t)=>{let n=typeof t==\"string\"?[t]:t;return Hw(e,r=>[...n,...r])}),g8=e=>wo(e)?hb(Pa)(e):sO(Pa)(e),x8=(e,t)=>hb(Pa)(en(function*(){let n=yield*Pa,r=wo(e)?yield*e:e;return t?.asPrimary?zw(r,n):zw(n,r)}));function y8(e,t){let n=t?.preserveEmptyStrings===!0;return dg(r=>x(b8(e,r,n)))}function b8(e,t,n){let r=e;for(let o of t){if(r==null)return;if(Array.isArray(r)){if(typeof o!=\"number\"||!Number.isInteger(o)||o<0||o>=r.length)return;r=r[o];continue}if(wt(r)){if(typeof o!=\"string\"||!Object.hasOwn(r,o))return;r=r[o];continue}return}return S8(r,n)}function S8(e,t){if(e!=null)return typeof e==\"string\"?XD(e,t):typeof e==\"number\"||typeof e==\"boolean\"||typeof e==\"bigint\"?pd(String(e)):Array.isArray(e)?Ww(e.length):wt(e)?lg(new Set(Object.keys(e))):pd(N(e))}function XD(e,t){let n=e1(e,t);return n===void 0?void 0:pd(n)}function e1(e,t){return e===\"\"&&!t?void 0:e}function Jw(e,t){let n=t?.preserveEmptyStrings===!0,r=E8(e);return dg(o=>x(w8(r,e,o,n)))}function t1(e){let t=e?.env??{...globalThis.process?.env,...import.meta?.env};return Jw(t,{preserveEmptyStrings:e?.preserveEmptyStrings})}function E8(e){let t={};for(let[n,r]of Object.entries(e)){if(r===void 0)continue;let o=n.split(\"_\"),s=t;for(let i of o){let a=s.children??=Object.create(null);s=a[i]??={}}}return t}var I8=/^(0|[1-9][0-9]*)$/;function w8(e,t,n,r){let o=n.map(String).join(\"_\"),s=e1(Object.hasOwn(t,o)?t[o]:void 0,r),i=T8(e,n),a=i?.children?Object.keys(i.children):[];if(a.length===0)return s===void 0?void 0:pd(s);if(a.every(u=>I8.test(u))){let u=Math.max(...a.map(f=>parseInt(f,10)))+1;return Ww(u,s)}return lg(new Set(a),s)}function T8(e,t){if(t.length===0)return e;let n=e;for(let r of t)if(n=n?.children?.[String(r)],!n)return;return n}function n1(e,t){let n=A8(e);return t?.expandVariables&&(n=C8(n)),Jw(n,{preserveEmptyStrings:t?.preserveEmptyStrings})}var v8=/(?:^|^)\\s*(?:export\\s+)?([\\w.-]+)(?:\\s*=\\s*?|:\\s+?)(\\s*'(?:\\\\'|[^'])*'|\\s*\"(?:\\\\\"|[^\"])*\"|\\s*`(?:\\\\`|[^`])*`|[^#\\r\\n]+)?\\s*(?:#.*)?(?:$|$)/mg;function A8(e){let t=Object.create(null);e=e.replace(/\\r\\n?/gm,`\n`);let n;for(;(n=v8.exec(e))!=null;){let r=n[1],o=n[2]||\"\";o=o.trim();let s=o[0];o=o.replace(/^(['\"`])([\\s\\S]*)\\1$/gm,\"$2\"),s==='\"'&&(o=o.replace(/\\\\n/g,`\n`),o=o.replace(/\\\\r/g,\"\\r\")),t[r]=o}return t}function C8(e){let t=Object.create(null);for(let n of Object.keys(e))t[n]=r1(e[n],e).replace(/\\\\\\$/g,\"$\");return t}function r1(e,t){let n=O8(e,/(?!(?<=\\\\))\\$/g);if(n===-1)return e;let r=e.slice(n),o=/((?!(?<=\\\\))\\${?([\\w]+)(?::-([^}\\\\]*))?}?)/,s=r.match(o);if(s!==null){let[i,a,c,u]=s,f=Object.hasOwn(t,c)&&t[c]!==\"\"?t[c]:u??\"\";return r1(e.replace(a,f),t)}return e}function O8(e,t){let n=Array.from(e.matchAll(t));return n.length>0?n.slice(-1)[0].index:-1}var k8=xe(function*(e){let n=yield*(yield*$w).readFileString(e?.path??\".env\");return n1(n,e)}),R8=xe(function*(e){let t=yield*Bw,n=yield*$w,r=e?.rootPath??\"/\",o=e?.preserveEmptyStrings===!0;return dg(s=>{let i=t.join(r,...s.map(String)),a=n.readFileString(i).pipe($(u=>XD(u.trim(),o))),c=n.readDirectory(i).pipe($(u=>lg(new Set(u.map(f=>t.basename(f))))));return a.pipe(Ws(u=>c.pipe(Ws(f=>qw(u)&&qw(f)?x(void 0):P(qw(u)?f:u)))),um(u=>new fg({message:`Failed to read file at ${t.join(r,...s.map(String))}`,cause:u})))})}),qw=e=>e.reason._tag===\"NotFound\";var o1=[\"All\",\"Fatal\",\"Error\",\"Warn\",\"Info\",\"Debug\",\"Trace\",\"None\"];var Kw=\"~effect/Config\",c1=e=>M(e,Kw),hu=class{_tag=\"ConfigError\";name=\"ConfigError\";cause;constructor(t){this.cause=t}get message(){return this.cause.toString()}toString(){return`ConfigError(${this.message})`}},_8={...Qd({label:\"Config\",evaluate(e){return this.parse(e.getRef(Pa))}}),[Kw]:Kw,toJSON(){return{_id:\"Config\"}}};function Do(e){let t=Object.create(_8);return t.evaluator=e,t.parse=n=>e(n,[]).pipe(fs(r=>r.error),lt(r=>r._tag===\"Resolved\"?x(r.value):P(r.error))),t}var Es=(e,t,n)=>e.evaluator(t,n),ci=(e,t)=>({_tag:\"Resolved\",value:e,hasInput:t}),M8=e=>({_tag:\"Absent\",error:e}),Lo=(e,t)=>({error:e,hasInput:t}),P8=e=>$t(e,\"SourceError\"),i1=(e,t)=>e.pipe(qf(n=>P8(n)?P(Lo(new hu(n),t)):hr(n))),F8=(e,t)=>t?e.pipe(fs(n=>Lo(n.error,!0)),lt(n=>n._tag===\"Resolved\"?x(ci(n.value,!0)):P(Lo(n.error,!0)))):e,u1=l(2,(e,t)=>Do((n,r)=>$(Es(e,n,r),o=>o._tag===\"Resolved\"?ci(t(o.value),o.hasInput):o))),U8=l(2,(e,t)=>Do((n,r)=>v(Es(e,n,r),o=>o._tag===\"Resolved\"?t(o.value).pipe(br(s=>ci(s,o.hasInput)),fs(s=>Lo(s,o.hasInput))):x(o)))),N8=l(2,(e,t)=>Do((n,r)=>Xb(Es(e,n,r),{onFailure:o=>F8(Es(t(o.error),n,r),o.hasInput),onSuccess:o=>o._tag===\"Absent\"?Es(t(o.error),n,r):x(o)})));function f1(e){let t=Array.isArray(e)?e:Symbol.iterator in e?[...e]:e;return Array.isArray(t)?Do((n,r)=>lt(rm(t.map(o=>am(Es(o,n,r)))),D8)):Do((n,r)=>lt(rm(zu(t,o=>am(Es(o,n,r)))),L8))}var D8=e=>{let t=[],n,r,o=!1;for(let s of e){if(ie(s)){n??=s.failure,o=o||s.failure.hasInput;continue}let i=s.success;i._tag===\"Absent\"?r??=i:(t.push(i.value),o=o||i.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},L8=e=>{let t={},n,r,o=!1;for(let s in e){let i=e[s];if(ie(i)){n??=i.failure,o=o||i.failure.hasInput;continue}let a=i.success;a._tag===\"Absent\"?r??=a:(F(t,s,a.value),o=o||a.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},l1=l(2,(e,t)=>Do((n,r)=>br(Es(e,n,r),o=>o._tag===\"Absent\"?ci(t,!1):o))),$8=e=>e.pipe(u1(k),l1(R())),d1=e=>c1(e)?e:f1(zu(e,t=>d1(t))),j8=()=>\"\",p1=(e,t)=>e.load(t).pipe(Kb,br(n=>({provider:e,path:t,node:n,toString:j8}))),a1=(e,t)=>p1(e.provider,[...e.path,t]),Gw=e=>e?.value,pg=(e,t)=>Dc(Jr,e,new Te(tn(n=>t(n)),Sn())),m1=e=>{switch(e._tag){case\"Union\":return e.types.every(m1);case\"Objects\":case\"Arrays\":case\"Suspend\":return!1;default:return!0}},Vw=(e,t)=>{switch(e._tag){case\"Objects\":return t?._tag===\"Record\";case\"Arrays\":return t?._tag===\"Array\";case\"Union\":return e.types.some(n=>Vw(n,t));case\"Suspend\":return Vw(e.thunk(),t);default:return Gw(t)!==void 0}},B8=It(e=>{let t=new WeakSet,n=jR(r=>{switch(t.add(r),r._tag){case\"Objects\":{let o=r.indexSignatures.map(i=>Ea(i.parameter)),s=xe(function*(i){if(i.node?._tag!==\"Record\")return;let a=i.node,c=new Set;for(let f of r.propertySignatures)typeof f.name==\"string\"&&c.add(f.name);if(o.length>0)for(let f of a.keys)o.some(d=>d(f))&&c.add(f);let u={};for(let f of c){let d=yield*a1(i,f);d.node!==void 0&&F(u,f,d)}return u});return pg(r.recur(n,i=>i),s)}case\"Arrays\":{let o=xe(function*(s){if(s.node?._tag!==\"Array\")return;let i=[];for(let a=0;ax(Gw(o.node))):r.recur(n);case\"Suspend\":{let o=r.thunk();return t.has(o)||n(o),r.recur(n)}case\"Declaration\":case\"Any\":throw new globalThis.Error(\"Config.schema does not support opaque StringTree encodings\",{cause:r});default:return pg(r,o=>x(Gw(o.node)))}});return n(e)});function vn(e,t){let n=uu(e),r=nn(n.ast),o=Ze(j(B8(n.ast))),s=typeof t==\"string\"?[t]:t??[];return Do((i,a)=>{let c=[...a,...s];return i1(p1(i,c),!1).pipe(lt(u=>{let f=Vw(r,u.node);return i1(o(u).pipe(br(d=>ci(d,f)),wc(d=>{let p=new hu(new bs(c.length>0?new Ve(c,d):d));return f?P(Lo(p,!0)):x(M8(p))})),f)}))})}var h1=so([\"true\",\"yes\",\"on\",\"1\",\"y\"]),g1=so([\"false\",\"no\",\"off\",\"0\",\"n\"]),x1=so([...h1.literals,...g1.literals]).pipe(ne(tg,We({decode:e=>e===\"true\"||e===\"yes\"||e===\"on\"||e===\"1\"||e===\"y\",encode:e=>e?\"true\":\"false\"}))),y1=Uo.check(cu({minimum:1,maximum:65535})),b1=so(o1),q8=(e,t,n)=>{let r=Kl(e,t),o=Gk(n),s=X.pipe(ne(uu(r),{decode:o.decode,encode:Sn({strict:!1}).compose(o.encode)}));return st([r,s])},z8=(e,t)=>{let n=Ye(e),r=t?.separator??\",\",o=X.pipe(ne(uu(n),{decode:Mk(t),encode:Sn({strict:!1}).compose(ce(s=>s.join(r)))}));return st([o,n])};function W8(e){return Do(()=>P(Lo(new hu(e),!1)))}function H8(e){return Do(()=>x(ci(e,!1)))}function J8(e){return vn(X,e)}function K8(e){return vn(dw,e)}function G8(e){return vn(au,e)}function V8(e){return vn(Xn,e)}function Z8(e){return vn(Uo,e)}function Y8(e,t){return vn(ze(e),t)}function Q8(e,t){return vn(so(e),t)}function X8(e){return vn(x1,e)}function eY(e){return vn(mw,e)}function tY(e){return vn(y1,e)}function nY(e){return vn(b1,e)}function rY(e){return vn(ed(X),e)}function oY(e){return vn(td,e)}function sY(e){return vn(er,e)}var iY=l(2,(e,t)=>Do((n,r)=>Es(e,n,[...r,t])));var gu={};uo(gu,{Console:()=>S1,assert:()=>aY,clear:()=>cY,consoleWith:()=>Ot,count:()=>uY,countReset:()=>fY,debug:()=>lY,dir:()=>dY,dirxml:()=>pY,error:()=>mY,group:()=>hY,info:()=>gY,log:()=>xY,table:()=>yY,time:()=>bY,timeLog:()=>SY,trace:()=>EY,warn:()=>IY,withGroup:()=>wY,withTime:()=>TY});var S1=Gy,Ot=e=>Y(t=>e(t.getRef(S1))),aY=(e,...t)=>Ot(n=>D(()=>{n.assert(e,...t)})),cY=Ot(e=>D(()=>{e.clear()})),uY=e=>Ot(t=>D(()=>{t.count(e)})),fY=e=>Ot(t=>D(()=>{t.countReset(e)})),lY=(...e)=>Ot(t=>D(()=>{t.debug(...e)})),dY=(e,t)=>Ot(n=>D(()=>{n.dir(e,t)})),pY=(...e)=>Ot(t=>D(()=>{t.dirxml(...e)})),mY=(...e)=>Ot(t=>D(()=>{t.error(...e)})),hY=e=>Ot(t=>Di(D(()=>{e?.collapsed?t.groupCollapsed(e.label):t.group(e?.label)}),()=>D(()=>{t.groupEnd()}))),gY=(...e)=>Ot(t=>D(()=>{t.info(...e)})),xY=(...e)=>Ot(t=>D(()=>{t.log(...e)})),yY=(e,t)=>Ot(n=>D(()=>{n.table(e,t)})),bY=e=>Ot(t=>Di(D(()=>{t.time(e)}),()=>D(()=>{t.timeEnd(e)}))),SY=(e,...t)=>Ot(n=>D(()=>{n.timeLog(e,...t)})),EY=(...e)=>Ot(t=>D(()=>{t.trace(...e)})),IY=(...e)=>Ot(t=>D(()=>{t.warn(...e)})),wY=l(e=>ee(e[0]),(e,t)=>Ot(n=>xf(D(()=>{t?.collapsed?n.groupCollapsed(t.label):n.group(t?.label)}),()=>e,()=>D(()=>{n.groupEnd()})))),TY=l(e=>ee(e[0]),(e,t)=>Ot(n=>xf(D(()=>{n.time(t)}),()=>e,()=>D(()=>{n.timeEnd(t)}))));var Dn={OK:200,Unauthorized:401,NotFound:404,InternalServerError:500,ServiceUnavailable:503},Zw={[Dn.OK]:\"OK\",[Dn.Unauthorized]:\"Unauthorized\",[Dn.NotFound]:\"Not Found\",[Dn.InternalServerError]:\"Internal Server Error\",[Dn.ServiceUnavailable]:\"Service Unavailable\"},vY=e=>{let t=e.startsWith(\"/\"),n=[];for(let o of e.split(\"/\"))if(!(o===\"\"||o===\".\")){if(o===\"..\"){n.length>0&&n.at(-1)!==\"..\"?n.pop():t||n.push(\"..\");continue}n.push(o)}let r=n.join(\"/\");return t?`/${r}`:r||\".\"},xu=(...e)=>vY(e.filter(Boolean).join(\"/\")),mg=e=>{if(e.length===0)return\".\";let t=e.length;for(;t>1&&e[t-1]===\"/\";)t-=1;let n=e.slice(0,t),r=n.lastIndexOf(\"/\");return r<0?\".\":r===0?\"/\":n.slice(0,r)},E1=e=>{if(!e.startsWith(\"/\"))throw new TypeError(\"Path must be absolute\");let t=new URL(\"file:///\");return t.pathname=e.replace(/%/g,\"%25\").replace(/\\\\/g,\"%5C\"),t};var AY=/^[A-Za-z0-9_-]+$/u,I1=/[*?[{]/u,Yw=(e,t)=>t===e||t.startsWith(`${e.replace(/\\/+$/u,\"\")}/`),yu=class extends bo.TaggedError(\"FunctionFileSystemError\"){},ao=(e,t)=>e.lstat(t).pipe(L.catch(()=>L.undefined)),Fa=(e,t,n)=>L.all([e.realPath(t),e.realPath(n)],{concurrency:2}).pipe(L.map(([r,o])=>Yw(r,o)),L.orElseSucceed(()=>!1)),w1=(e,t,n)=>L.gen(function*(){let r=yield*ao(e,n);if(r===void 0)return!0;if(r.isSymbolicLink||!(yield*Fa(e,t,n)))return!1;if(!r.isDirectory)return!0;let o=yield*e.readDirectory(n).pipe(L.catch(()=>L.undefined));if(o===void 0)return!1;for(let s of o)if(!(yield*w1(e,t,xu(n,s))))return!1;return!0}),md=(e,t)=>t.length===0?\"\":t.startsWith(\"/\")?t:xu(e,t),T1=L.fn(\"Functions.resolveFunctionConfig\")(function*(e){let{root:t,slug:n,overrides:r,fs:o}=e;if(!t.startsWith(\"/\")||!AY.test(n)||n===\"_shared\"||(yield*ao(o,t))===void 0)return;let i=yield*o.realPath(t).pipe(L.orElseSucceed(()=>\"\"));if(!i.startsWith(\"/\"))return;let a=yield*ao(o,i);if(a===void 0||!a.isDirectory)return;let c=e.filesRoot===void 0?i:yield*o.realPath(e.filesRoot).pipe(L.orElseSucceed(()=>\"\"));if(!c.startsWith(\"/\")||!Yw(c,i))return;let u=r.$default,f=r[n],d={...u,...f};if(d.enabled===!1)return;let p=xu(i,n),m=d?.entrypointPath&&d.entrypointPath.length>0?d.entrypointPath:d.entrypoint&&d.entrypoint.length>0?d.entrypoint:\"index.ts\";if(!m.startsWith(\"/\")){let T=yield*ao(o,p);if(T===void 0||!T.isDirectory||!(yield*Fa(o,i,p)))return}let g=md(p,m);if(!(yield*Fa(o,c,g)))return;let b=yield*ao(o,g);if(b===void 0||!b.isFile||b.isSymbolicLink)return;let E=f?.importMapPath??f?.import_map,w=u?.importMapRoot??u?.import_map_root,S=E!==void 0?md(p,E):w!==void 0?md(i,w):md(p,\"\");if(S.length>0){if(!(yield*Fa(o,c,S)))return;let T=yield*ao(o,S);if(T===void 0||!T.isFile||T.isSymbolicLink)return}else for(let T of[\"deno.json\",\"deno.jsonc\"]){let y=xu(p,T),I=yield*ao(o,y);if(I!==void 0){if(!I.isFile||I.isSymbolicLink||!(yield*Fa(o,c,y)))return;S=y;break}}let h=(d.staticFiles??d.static_files??[]).map(T=>md(p,T));for(let T of h){if(!Yw(c,T))return;let y=T.search(I1),I=y<0?T:T.slice(0,y),A=y<0?mg(T):I.slice(0,Math.max(0,I.lastIndexOf(\"/\")))||i;if(!(yield*w1(o,c,A)))return;if(!I1.test(T)){let C=yield*ao(o,T);if(C!==void 0&&(!(yield*Fa(o,c,T))||C.isSymbolicLink))return}}return{entrypointPath:g,importMapPath:S,staticFiles:h,verifyJWT:d.verifyJWT??d.verify_jwt??!0,env:d.env}}),CY=e=>xu(mg(e.entrypointPath),\"package.json\"),v1=e=>{let t=new Map,n=new Map;return(r,o)=>{let s=n.get(r);if(s!==void 0)return s;let i=mg(o.entrypointPath),a=t.get(i),c=a===void 0||a===r?i:e();return a===void 0&&t.set(i,r),n.set(r,c),c}},A1=L.fn(\"Functions.packageJsonContainedFor\")(function*(e){if(!e.root.startsWith(\"/\"))return!1;let t=yield*ao(e.fs,e.root);if(t===void 0||!t.isDirectory&&!t.isSymbolicLink)return!1;let n=yield*e.fs.realPath(e.root).pipe(L.orElseSucceed(()=>\"\"));if(!n.startsWith(\"/\"))return!1;let r=yield*ao(e.fs,n);if(r===void 0||!r.isDirectory)return!1;let o=CY(e.config),s=yield*ao(e.fs,o);return s===void 0||!s.isFile||s.isSymbolicLink?!1:yield*Fa(e.fs,n,o)});var Qw=new TextEncoder,hd=new TextDecoder,hg=new TextDecoder(\"utf-8\",{fatal:!0}),jre=2**32;function C1(...e){let t=e.reduce((o,{length:s})=>o+s,0),n=new Uint8Array(t),r=0;for(let o of e)n.set(o,r),r+=o.length;return n}function bu(e){let t=new Uint8Array(e.length);for(let n=0;n127)throw new TypeError(\"non-ASCII string encountered in encode()\");t[n]=r}return t}var gg=(e,t=\"algorithm.name\")=>new TypeError(`CryptoKey does not support this operation, its ${t} must be ${e}`);function OY(e,t){if(t&&!e.usages.includes(t))throw new TypeError(`CryptoKey does not support this operation, its usages must include ${t}.`)}function O1(e,t){let{modulusLength:n}=t.algorithm;if(typeof n!=\"number\"||n<2048)throw new TypeError(`${e} requires key modulusLength to be 2048 bits or larger`)}function k1(e,t,n){let r=e.algorithm;if(r.name!==t.name)throw gg(t.name);if(t.hash&&r.hash?.name!==t.hash)throw gg(t.hash,\"algorithm.hash\");if(t.namedCurve&&r.namedCurve!==t.namedCurve)throw gg(t.namedCurve,\"algorithm.namedCurve\");if(t.length!==void 0&&r.length!==t.length)throw gg(t.length,\"algorithm.length\");OY(e,n)}function kY(e,t,...n){if(n.length>2){let r=n.pop();e+=`one of type ${n.join(\", \")}, or ${r}.`}else n.length===2?e+=`one of type ${n[0]} or ${n[1]}.`:e+=`of type ${n[0]}.`;return t==null?e+=` Received ${t}`:typeof t==\"function\"&&t.name?e+=` Received function ${t.name}`:typeof t==\"object\"&&t!=null&&t.constructor?.name&&(e+=` Received an instance of ${t.constructor.name}`),e}var gd=(e,t,...n)=>kY(`Key for the ${e} algorithm must be `,t,...n);var an=class extends Error{static code=\"ERR_JOSE_GENERIC\";code=\"ERR_JOSE_GENERIC\";constructor(t,n){super(t,n),this.name=this.constructor.name,Error.captureStackTrace?.(this,this.constructor)}},Is=class extends an{static code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},xd=class extends an{static code=\"ERR_JWT_EXPIRED\";code=\"ERR_JWT_EXPIRED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},xg=class extends an{static code=\"ERR_JOSE_ALG_NOT_ALLOWED\";code=\"ERR_JOSE_ALG_NOT_ALLOWED\"},co=class extends an{static code=\"ERR_JOSE_NOT_SUPPORTED\";code=\"ERR_JOSE_NOT_SUPPORTED\"};var Ln=class extends an{static code=\"ERR_JWS_INVALID\";code=\"ERR_JWS_INVALID\"},Su=class extends an{static code=\"ERR_JWT_INVALID\";code=\"ERR_JWT_INVALID\"};var yd=class extends an{static code=\"ERR_JWKS_INVALID\";code=\"ERR_JWKS_INVALID\"},Eu=class extends an{static code=\"ERR_JWKS_NO_MATCHING_KEY\";code=\"ERR_JWKS_NO_MATCHING_KEY\";constructor(t=\"no applicable key found in the JSON Web Key Set\",n){super(t,n)}},yg=class extends an{[Symbol.asyncIterator]=async function*(){};static code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";constructor(t=\"multiple matching keys found in the JSON Web Key Set\",n){super(t,n)}},bg=class extends an{static code=\"ERR_JWKS_TIMEOUT\";code=\"ERR_JWKS_TIMEOUT\";constructor(t=\"request timed out\",n){super(t,n)}},Sg=class extends an{static code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";constructor(t=\"signature verification failed\",n){super(t,n)}};var Xw=e=>{if(e?.[Symbol.toStringTag]===\"CryptoKey\")return!0;try{return e instanceof CryptoKey}catch{return!1}},RY=e=>e?.[Symbol.toStringTag]===\"KeyObject\",R1=e=>Xw(e)||RY(e);function _1(e){if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);let t=atob(e),n=new Uint8Array(t.length);for(let r=0;rtypeof o!=\"string\")||new Set(r).size!==r.length)throw new TypeError('\"key_ops\" (Key Operations) Parameter must be an array of unique strings');t.key_ops=r}return t}var r0=e=>e[Symbol.toStringTag],_Y=(e,t,n)=>{let{alg:r}=e;if(t.use!==void 0){let o=n===\"sign\"||n===\"verify\"?\"sig\":\"enc\";if(t.use!==o)throw new TypeError(`Invalid key for this operation, its \"use\" must be \"${o}\" when present`)}if(t.alg!==void 0&&t.alg!==r)throw new TypeError(`Invalid key for this operation, its \"alg\" must be \"${r}\" when present`);if(Array.isArray(t.key_ops)){let o=n===\"encrypt\"||n===\"decrypt\"?e.ops?.[n===\"encrypt\"?0:1]:n;if(o&&!t.key_ops.includes(o))throw new TypeError(`Invalid key for this operation, its \"key_ops\" must include \"${o}\" when present`)}};function MY(e,t,n){let{alg:r,secret:o}=e,s=n===\"decrypt\"||n===\"sign\";if(o&&t instanceof Uint8Array)return[U1,t];if(ui(t)){let i=P1(t);if(typeof i.kty!=\"string\")throw new TypeError(o?gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(!(o?i.kty===\"oct\"&&typeof i.k==\"string\":i.kty!==\"oct\"&&(s?i.kty===\"AKP\"&&typeof i.priv==\"string\"||typeof i.d==\"string\":i.d===void 0&&i.priv===void 0)))throw new TypeError(o?'JSON Web Key for symmetric algorithms must have JWK \"kty\" (Key Type) equal to \"oct\" and the JWK \"k\" (Key Value) present':`JSON Web Key for this operation must be a ${s?\"private\":\"public\"} JWK`);return _Y(e,i,n),[L1,t,i]}if(!R1(t))throw new TypeError(o?gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(o){if(t.type!==\"secret\")throw new TypeError(`${r0(t)} instances for symmetric algorithms must be of type \"secret\"`)}else{if(t.type===\"secret\")throw new TypeError(`${r0(t)} instances for asymmetric algorithms must not be of type \"secret\"`);let i=s?\"private\":\"public\";if((t.type===\"public\"||t.type===\"private\")&&t.type!==i){let a=n===\"sign\"?\"signing\":n===\"verify\"?\"verifying\":`${n.slice(0,-1)}tion`;throw new TypeError(`${r0(t)} instances for asymmetric algorithm ${a} must be of type \"${i}\"`)}}return Xw(t)?[N1,t]:[D1,t]}var U1=0,N1=1,D1=2,L1=3,o0,PY={__proto__:null,prime256v1:\"P-256\",secp384r1:\"P-384\",secp521r1:\"P-521\"};function Tg(e,t,n){o0||=new WeakMap;let r=o0.get(e);return n&&(r?r[t]=n:o0.set(e,{[t]:n})),n??r?.[t]}var F1=async(e,t,n)=>Tg(e,n.alg)??Tg(e,n.alg,await wg(n,{...t,alg:n.alg})),FY=(e,t)=>{let n=Tg(e,t.alg);if(n)return n;let r=e.type===\"public\",o=t.usages[r?0:1],{asymmetricKeyType:s}=e,i=PY[e.asymmetricKeyDetails?.namedCurve],a=t.resolve?.({crv:i,asymmetricKeyType:s})??t.subtle;return Tg(e,t.alg,e.toCryptoKey(a,r,o))};async function $1(e,t,n){let r=MY(e,t,n);switch(r[0]){case U1:case N1:return r[1];case L1:{let o=r[1],s=r[2];if(s.kty===\"oct\")return bd(s.k);if(!Object.isFrozen(o)){let{key_ops:i}=o;Array.isArray(i)&&Object.freeze(i),Object.freeze(o)}return F1(o,s,e)}case D1:{let o=r[1];return o.type===\"secret\"?o.export():\"toCryptoKey\"in o&&typeof o.toCryptoKey==\"function\"?FY(o,e):F1(o,o.export({format:\"jwk\"}),e)}}}function j1(e){let t={__proto__:null};for(let n in e)t[n]={...e[n],alg:n};return t}var B1={__proto__:null,b64:!0};function q1(e,t){if(t!==void 0&&(!Array.isArray(t)||t.some(n=>typeof n!=\"string\")))throw new TypeError(`\"${e}\" option must be an array of strings`);if(t)return new Set(t)}function z1(e,t,n,r,o){if(o.crit!==void 0&&r?.crit===void 0)throw new e('\"crit\" (Critical) Header Parameter MUST be integrity protected');if(!r||r.crit===void 0)return[];if(!Array.isArray(r.crit)||r.crit.length===0||r.crit.some(i=>typeof i!=\"string\"||i.length===0))throw new e('\"crit\" (Critical) Header Parameter MUST be an array of non-empty strings when present');let s=n===void 0?t:{__proto__:null,...n,...t};for(let i of r.crit){if(!(i in s))throw new co(`Extension Header Parameter \"${i}\" is not recognized`);if(!Object.hasOwn(o,i)||o[i]===void 0)throw new e(`Extension Header Parameter \"${i}\" is missing`);if(s[i]&&(!Object.hasOwn(r,i)||r[i]===void 0))throw new e(`Extension Header Parameter \"${i}\" MUST be integrity protected`)}return r.crit}function W1(e,t){if(t.includes(\"b64\")){let n=e.b64;if(typeof n!=\"boolean\")throw new Ln('The \"b64\" (base64url-encode payload) Header Parameter must be a boolean');return n}return!0}async function UY(e,t,n){return t instanceof Uint8Array?crypto.subtle.importKey(\"raw\",t,e.subtle,!1,[n]):(k1(t,e.subtle,n),e.minRsaBits&&O1(e.alg,t),t)}async function H1(e,t,n,r){let o=await UY(e,t,\"verify\");try{return await crypto.subtle.verify(e.signing,o,n,r)}catch{return!1}}var Sd=[[\"verify\"],[\"sign\"]];function s0(e){let t={name:\"HMAC\",hash:`SHA-${e}`};return{kty:[\"oct\"],secret:!0,subtle:t,signing:t,usages:Sd}}function Iu(e,t){let r={name:t?\"RSA-PSS\":\"RSASSA-PKCS1-v1_5\",hash:`SHA-${e}`};return{kty:[\"RSA\"],subtle:r,signing:t?{...r,saltLength:t}:r,usages:Sd,minRsaBits:2048}}function i0(e,t){return{kty:[\"EC\"],crv:e,subtle:{name:\"ECDSA\",namedCurve:e},signing:{name:\"ECDSA\",hash:`SHA-${t}`},usages:Sd}}function J1(){let e={name:\"Ed25519\"};return{kty:[\"OKP\"],crv:\"Ed25519\",subtle:e,signing:e,usages:Sd}}function a0(e){let n={name:`ML-DSA-${e}`};return{kty:[\"AKP\"],subtle:n,signing:n,usages:Sd}}var c0=j1({HS256:s0(256),HS384:s0(384),HS512:s0(512),RS256:Iu(256),RS384:Iu(384),RS512:Iu(512),PS256:Iu(256,32),PS384:Iu(384,48),PS512:Iu(512,64),ES256:i0(\"P-256\",256),ES384:i0(\"P-384\",384),ES512:i0(\"P-521\",512),EdDSA:J1(),Ed25519:J1(),\"ML-DSA-44\":a0(44),\"ML-DSA-65\":a0(65),\"ML-DSA-87\":a0(87)});function K1(e){let t=typeof e==\"string\"?c0[e]:void 0;if(!t)throw new co(`alg ${e} is not supported either by JOSE or your javascript runtime`);return t}function G1(e){return[e&&q1(\"algorithms\",e.algorithms),e?.crit]}function NY(e,t=e===void 0?{}:Ig(e,Ln,\"JWS Protected Header is invalid\")){return t}function DY(e,t,n){let r=W1(e,z1(Ln,B1,n[1],e,t)),o=t.alg;if(typeof o!=\"string\"||!o)throw new Ln('JWS \"alg\" (Algorithm) Header Parameter missing or invalid');if(n[0]&&!n[0].has(o))throw new xg('\"alg\" (Algorithm) Header Parameter value not allowed');return[r,o]}function LY(e){try{return bu(e)}catch{throw new Ln(\"JWS Compact Serialization payload must use only ASCII characters\")}}async function $Y(e,t,n,r,o,s,i){let a=!1;typeof n==\"function\"&&(n=await n(o,e),a=!0);let c=typeof i==\"string\",u=K1(s),f=C1(r!==void 0?bu(r):new Uint8Array,bu(\".\"),c?t[2]??=M1(i,\"payload\",Ln):i),d=t0(e.signature,\"signature\",Ln),p=await $1(u,n,\"verify\");if(!await H1(u,p,d,f))throw new Sg;return[c?t0(i,\"payload\",Ln):i,o,c,p,a]}async function V1(e,t,n){if(e instanceof Uint8Array&&(e=hd.decode(e)),typeof e!=\"string\")throw new Ln(\"Compact JWS must be a string or Uint8Array\");let{0:r,1:o,2:s,length:i}=e.split(\".\");if(i!==3)throw new Ln(\"Invalid Compact JWS\");let a={payload:o,protected:r,signature:s},c=NY(r),[u,f]=DY(c,c,t),d=u?o:LY(o);return $Y(a,t,n,r,c,f,d)}var jY=e=>Math.floor(e.getTime()/1e3),BY={s:1,m:60,h:3600,d:86400,w:604800,y:31557600},qY=/^(\\+|\\-)? ?(\\d+|\\d+\\.\\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i,wu=\"check_failed\";function u0(){throw new TypeError(\"Invalid time period format\")}function Z1(e){typeof e!=\"string\"&&u0();let t=qY.exec(e);(!t||t[4]&&t[1])&&u0();let n=parseFloat(t[2]),r=Math.round(n*BY[t[3][0].toLowerCase()]);return Number.isFinite(r)||u0(),t[1]===\"-\"||t[4]===\"ago\"?-r:r}function f0(e,t){if(!Number.isFinite(t))throw new TypeError(`Invalid ${e} input`);return t}var Y1=e=>{let t=e.toLowerCase();return e.includes(\"/\")?t:`application/${t}`},zY=(e,t)=>typeof e==\"string\"?t.includes(e):Array.isArray(e)?t.some(n=>e.includes(n)):!1;function l0(e,t,n=!1){let r=e[t];if(!(r===void 0&&!n)){if(typeof r!=\"number\")throw new Is(`\"${t}\" claim must be a number`,e,t,\"invalid\");return r}}function d0(e,t){throw new Is(`unexpected \"${t}\" claim value`,e,t,wu)}function Q1(e,t,n={}){let r;try{r=JSON.parse(hg.decode(t))}catch{}if(!ui(r))throw new Su(\"JWT Claims Set must be a top-level JSON object\");let{typ:o}=n;if(o!==void 0&&(typeof e.typ!=\"string\"||Y1(e.typ)!==Y1(o)))throw new Is('unexpected \"typ\" JWT header value',r,\"typ\",wu);let{requiredClaims:s=[],issuer:i,subject:a,audience:c,maxTokenAge:u}=n,f=[...s];u!==void 0&&f.push(\"iat\"),c!==void 0&&f.push(\"aud\"),a!==void 0&&f.push(\"sub\"),i!==void 0&&f.push(\"iss\");for(let S of new Set(f.reverse()))if(!Object.hasOwn(r,S))throw new Is(`missing required \"${S}\" claim`,r,S,\"missing\");i!==void 0&&!(Array.isArray(i)?i:[i]).includes(r.iss)&&d0(r,\"iss\"),a!==void 0&&r.sub!==a&&d0(r,\"sub\"),c!==void 0&&!zY(r.aud,typeof c==\"string\"?[c]:c)&&d0(r,\"aud\");let{clockTolerance:d}=n,p=0;if(typeof d==\"string\")p=Z1(d);else if(d!==void 0){if(typeof d!=\"number\")throw new TypeError(\"Invalid clockTolerance option type\");p=d}f0(\"clockTolerance option\",p);let{currentDate:m}=n,g=f0(\"currentDate option\",jY(m===void 0?new Date:m)),b=l0(r,\"iat\",u!==void 0),E=l0(r,\"nbf\");if(E!==void 0&&E>g+p)throw new Is('\"nbf\" claim timestamp check failed',r,\"nbf\",wu);let w=l0(r,\"exp\");if(w!==void 0&&w<=g-p)throw new xd('\"exp\" claim timestamp check failed',r,\"exp\",wu);if(u!==void 0){let S=g-b,h=f0(\"maxTokenAge option\",typeof u==\"number\"?u:Z1(u));if(S-p>h)throw new xd('\"iat\" claim timestamp check failed (too far in the past)',r,\"iat\",wu);if(S<-p)throw new Is('\"iat\" claim timestamp check failed (it should be in the past)',r,\"iat\",wu)}return r}async function vg(e,t,n){let r=await V1(e,G1(n),t);if(!r[2])throw new Su(\"JWTs MUST NOT use unencoded payload\");let s={payload:Q1(r[1],r[0],n),protectedHeader:r[1]};return typeof t==\"function\"?{...s,key:r[3]}:s}function WY(e,t,n,r){let{kty:o,key_ops:s,ext:i,kid:a,alg:c,use:u,crv:f}=n0(e),d=Array.isArray(s)?[...s]:s;return(i===void 0||typeof i==\"boolean\")&&(d===void 0||Array.isArray(d)&&d.every((p,m)=>typeof p==\"string\"&&d.indexOf(p)===m)&&d.includes(\"verify\"))&&t.kty.includes(o)&&(r===void 0||typeof r==\"string\"&&r===a)&&(c===void 0?o!==\"AKP\":n===c)&&(u===void 0||u===\"sig\")&&(!t.crv||f===t.crv)}async function X1(e,t,n){let r=e.get(t)||e.set(t,{}).get(t),{alg:o}=n;if(r[o]===void 0){let s=await wg(n,{...t,alg:o,ext:!0});if(s.type!==\"public\")throw new yd(\"JSON Web Key Set members must be public keys\");r[o]=s}return r[o]}function Ua(e){let t;try{t=structuredClone(e)}catch{}if(!Eg(t))throw new yd(\"JSON Web Key Set malformed\");let n=new WeakMap;return Object.defineProperty(async(o,s)=>{let{alg:i,kid:a}={...o,...s?.header},c=typeof i==\"string\"?c0[i]:void 0;if(!c||c.secret)throw new co('Unsupported \"alg\" value for a JSON Web Key Set');let u=t.keys.filter(p=>WY(p,c,i,a)),{0:f,length:d}=u;if(!d)throw new Eu;if(d!==1){let p=new yg;throw p[Symbol.asyncIterator]=async function*(){for(let m of u)try{yield await X1(n,m,c)}catch{}},p}return X1(n,f,c)},\"jwks\",{value:()=>structuredClone(t)})}function HY(){return typeof WebSocketPair<\"u\"||typeof navigator<\"u\"&&navigator.userAgent===\"Cloudflare-Workers\"||typeof EdgeRuntime<\"u\"&&EdgeRuntime===\"vercel\"}var p0;(typeof navigator>\"u\"||!navigator.userAgent?.startsWith?.(\"Mozilla/5.0 \"))&&(p0=\"jose/v6.2.10\");var t2=Symbol();async function JY(e,t,n,r=fetch){let o=await r(e,{method:\"GET\",signal:n,redirect:\"manual\",headers:t}).catch(s=>{throw s.name===\"TimeoutError\"?new bg:s});if(o.status!==200)throw new an(\"Expected 200 OK from the JSON Web Key Set HTTP response\");try{return await o.json()}catch{throw new an(\"Failed to parse the JSON Web Key Set HTTP response as JSON\")}}var n2=Symbol();function Ed(e,t){return Number.isFinite(e)&&Date.now(){if(p&&HY()&&(p=void 0),!p){let S=++m,h=p=JY(n,c,AbortSignal.timeout(s),u).then(T=>{let y=Ua(T);if(S<=g)return;b=y;let I=Date.now();f&&(f.uat=I,f.jwks=T),d=I,g=S}).finally(()=>{p===h&&(p=void 0)})}await p};return Object.defineProperties(async(S,h)=>{(!b||!Ed(d,a))&&await E();try{return await b(S,h)}catch(T){if(T instanceof Eu&&!Ed(d,i))return await E(),b(S,h);throw T}},{coolingDown:{get:()=>Ed(d,i),enumerable:!0},fresh:{get:()=>Ed(d,a),enumerable:!0},reload:{value:E,enumerable:!0},reloading:{get:()=>!!p,enumerable:!0},jwks:{value:()=>b?.jwks(),enumerable:!0}})}function h0(e){let t;if(typeof e==\"string\"){let r=e.split(\".\");(r.length===3||r.length===5)&&([t]=r)}else if(typeof e==\"object\"&&e)if(\"protected\"in e)t=e.protected;else throw new TypeError(\"Token does not contain a Protected Header\");let n=\"Invalid Token or Protected Header formatting\";if(typeof t!=\"string\"||!t)throw new TypeError(n);return Ig(t,TypeError,n)}var GY=new Set([\"HOME\",\"HOSTNAME\",\"PATH\",\"PWD\"]),kr=L.runSync(L.gen(function*(){let e=t=>t.pipe(An.option);return{hostPort:yield*e(An.string(\"SUPABASE_INTERNAL_HOST_PORT\")),functionsRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_ROOT\")),filesRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_FILES_ROOT\")),jwtSecret:yield*e(An.string(\"SUPABASE_INTERNAL_JWT_SECRET\")),supabaseUrl:yield*e(An.string(\"SUPABASE_URL\")),wallclock:yield*e(An.string(\"SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC\")),publishableKey:yield*e(An.string(\"SUPABASE_INTERNAL_PUBLISHABLE_KEY\")),secretKey:yield*e(An.string(\"SUPABASE_INTERNAL_SECRET_KEY\")),functionsConfig:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_CONFIG\")),debug:yield*e(An.string(\"SUPABASE_INTERNAL_DEBUG\")),jwks:yield*e(An.string(\"SUPABASE_JWKS\"))}}).pipe(L.provideService(mu.ConfigProvider,mu.fromEnvRecord(Deno.env.toObject(),{preserveEmptyStrings:!0})))),vu=(e,t=\"\")=>ye.getOrElse(e,()=>t),r2=vu(kr.hostPort,\"8081\"),c2=vu(kr.functionsRoot),VY=vu(kr.jwtSecret),ZY=vu(kr.supabaseUrl,\"http://127.0.0.1:54321\"),YY=new URL(\"/auth/v1/.well-known/jwks.json\",ZY),o2=Number.parseInt(vu(kr.wallclock,\"400\"),10),s2=ye.getOrUndefined(kr.publishableKey),i2=ye.getOrUndefined(kr.secretKey),ws={BootError:Dn.ServiceUnavailable,InvalidWorkerResponse:Dn.InternalServerError,WorkerLimit:546},QY={[ws.BootError]:\"BOOT_ERROR\",[ws.InvalidWorkerResponse]:\"WORKER_ERROR\",[ws.WorkerLimit]:\"WORKER_LIMIT\"},XY={[ws.BootError]:\"Worker failed to boot (please check logs)\",[ws.InvalidWorkerResponse]:\"Function exited due to an error (please check logs)\",[ws.WorkerLimit]:\"Worker failed to respond due to a resource limit (please check logs)\"},eQ=new Map([[Deno.errors.InvalidWorkerCreation,ws.BootError],[Deno.errors.InvalidWorkerResponse,ws.InvalidWorkerResponse],[Deno.errors.WorkerRequestCancelled,ws.WorkerLimit]]),tQ=e=>{let t=Deno.errors.WorkerAlreadyRetired;return t!==void 0&&e instanceof t},nQ=(s=>(s.MissingAuthHeader=\"UNAUTHORIZED_NO_AUTH_HEADER\",s.InvalidLegacyJWT=\"UNAUTHORIZED_JWT\",s.InvalidAsymmetricJWT=\"UNAUTHORIZED_ASYMMETRIC_JWT\",s.InvalidTokenFormat=\"UNAUTHORIZED_INVALID_JWT_FORMAT\",s.UnsupportedTokenAlgorithm=\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",s))(nQ||{}),rQ=fe.Struct({enabled:fe.optionalKey(fe.Boolean),verifyJWT:fe.optionalKey(fe.Boolean),verify_jwt:fe.optionalKey(fe.Boolean),entrypointPath:fe.optionalKey(fe.String),entrypoint:fe.optionalKey(fe.String),importMapPath:fe.optionalKey(fe.String),import_map:fe.optionalKey(fe.String),importMapRoot:fe.optionalKey(fe.String),import_map_root:fe.optionalKey(fe.String),staticFiles:fe.optionalKey(fe.Array(fe.String)),static_files:fe.optionalKey(fe.Array(fe.String)),env:fe.optionalKey(fe.Record(fe.String,fe.String))}),oQ=fe.Record(fe.String,rQ),sQ=fe.declare(e=>typeof e==\"object\"&&e!==null&&\"keys\"in e&&Array.isArray(e.keys)&&e.keys.every(t=>typeof t==\"object\"&&t!==null)),iQ=()=>ye.match(kr.functionsConfig,{onNone:()=>({}),onSome:e=>L.runSync(fe.decodeEffect(fe.fromJsonString(oQ))(e).pipe(L.orElseSucceed(()=>({}))))}),g0=iQ();if(ye.getOrUndefined(kr.debug)===\"true\"){let e=Object.fromEntries(Object.entries(g0).map(([t,n])=>[t,Object.fromEntries(Object.entries(n).filter(([r])=>r!==\"env\"))]));L.runSync(gu.log(\"Functions config:\",JSON.stringify(e,null,2)))}var Na=(e,t,n={})=>{let r={...n},o=null;return e!=null&&(typeof e==\"object\"?(r[\"Content-Type\"]=\"application/json\",o=JSON.stringify(e)):(r[\"Content-Type\"]=\"text/plain\",o=typeof e==\"string\"?e:JSON.stringify(e)??null)),new Response(o,{status:t,headers:r})},a2=({code:e,message:t=\"Invalid JWT\"})=>Na({code:e,message:t,msg:t},Dn.Unauthorized,{\"sb-error-code\":e,\"Access-Control-Expose-Headers\":\"sb-error-code\"}),aQ=e=>{for(let[t,n]of eQ.entries())if(t!==void 0&&e instanceof t)return Na({code:QY[n],message:XY[n]},n);return Na({code:Zw[Dn.InternalServerError],message:\"Request failed due to an internal server error\"},Dn.InternalServerError)};function cQ(e){let t=e.split(\" \");return t.length===2&&t[0]===\"Bearer\"?t[1]:null}var uQ=e=>{let t=e.headers.get(\"authorization\"),n=e.headers.get(\"sb-api-key\")?.replace(\"Bearer\",\"\").trim();if(!t&&!n)return{code:\"UNAUTHORIZED_NO_AUTH_HEADER\",message:\"Missing authorization header\"};let r=cQ(t??\"\"),o=!r||r.startsWith(\"sb_\")?n:r;return o||{code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"}},Tu=class extends bo.TaggedError(\"BootstrapOperationError\"){},fQ=L.runSync(ye.match(kr.jwks,{onNone:()=>L.succeed(ye.some(Ua({keys:[]}))),onSome:e=>L.gen(function*(){let t=yield*fe.decodeEffect(fe.fromJsonString(sQ))(e);return yield*L.try({try:()=>Ua(t),catch:n=>new Tu({cause:n})})}).pipe(L.option)})),lQ=ye.getOrElse(fQ,()=>m0(YY)),fi=e=>L.tryPromise({try:e,catch:t=>new Tu({cause:t})}),dQ=e=>L.gen(function*(){return yield*fi(()=>vg(e,lQ)),ye.none()}).pipe(L.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_ASYMMETRIC_JWT\"})));function pQ(e,t){return L.gen(function*(){let n=yield*L.try({try:()=>h0(t).alg,catch:r=>new Tu({cause:r})});return n?n===\"HS256\"?yield*fi(()=>vg(t,new TextEncoder().encode(e))).pipe(L.as(ye.none()),L.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_JWT\"}))):n===\"ES256\"||n===\"RS256\"?yield*dQ(t):ye.some({code:\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",message:`Unsupported JWT algorithm ${n}`}):ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})}).pipe(L.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})))}var u2={lstat:e=>fi(()=>Deno.lstat(e)).pipe(L.mapError(t=>new yu({cause:t})),L.map(t=>({isDirectory:t.isDirectory,isFile:t.isFile,isSymbolicLink:t.isSymlink}))),realPath:e=>fi(()=>Deno.realPath(e)).pipe(L.mapError(t=>new yu({cause:t}))),readDirectory:e=>No.suspend(()=>No.fromAsyncIterable(Deno.readDir(e),t=>new Tu({cause:t})).pipe(No.map(t=>t.name))).pipe(No.runCollect,L.mapError(t=>new yu({cause:t})))},mQ=e=>T1({root:c2,filesRoot:ye.getOrUndefined(kr.filesRoot),slug:e,overrides:g0,fs:u2}),hQ=v1(()=>Deno.makeTempDirSync({prefix:\"supabase-worker-\"})),gQ=e=>e.importMapPath?L.succeed(!1):A1({root:vu(kr.filesRoot,c2),config:e,fs:u2}),f2=e=>No.fromEffectRepeat(fi(()=>e.read()).pipe(L.flatMap(t=>t.done?Wt.done():L.succeed(t.value)))),xQ=e=>e===void 0?L.void:No.runDrain(f2(e)).pipe(L.ignore);function yQ(e,t){let n=new URL(e.url),r=e.headers.get(\"x-forwarded-host\");r&&(n.hostname=r);let o=new Request(n.href,{method:e.method,headers:e.headers,body:t===void 0?null:No.toReadableStream(f2(t)),signal:e.signal,duplex:\"half\"});return o.headers.delete(\"sb-api-key\"),EdgeRuntime.applySupabaseTag(e,o),o}Deno.serve({handler:e=>L.runPromiseExit(L.gen(function*(){let t=yield*L.acquireRelease(L.sync(()=>e.body?.getReader()),u=>L.interruptible(xQ(u))),{pathname:n}=new URL(e.url);if(n===\"/_internal/health\")return Na({message:\"ok\"},Dn.OK);if(n===\"/_internal/metric\")return Response.json(yield*fi(()=>EdgeRuntime.getRuntimeMetrics()));let r=n.split(\"/\")[1];if(!r)return Na(\"Function not found\",Dn.NotFound);let o=yield*mQ(r);if(!o)return Na(\"Function not found\",Dn.NotFound);if(e.method!==\"OPTIONS\"&&o.verifyJWT){let u=uQ(e);if(typeof u!=\"string\")return a2(u);let f=yield*pQ(VY,u);if(ye.isSome(f))return a2(f.value)}let s={...Deno.env.toObject(),...Object.fromEntries(Object.entries(o.env??{}).filter(([u])=>!u.startsWith(\"SUPABASE_\"))),SUPABASE_FUNCTION_SLUG:r};s2&&(s.SUPABASE_PUBLISHABLE_KEYS=yield*fe.encodeEffect(fe.fromJsonString(fe.Unknown))({default:s2})),i2&&(s.SUPABASE_SECRET_KEYS=yield*fe.encodeEffect(fe.fromJsonString(fe.Unknown))({default:i2}));let i=Object.entries(s).filter(([u])=>!GY.has(u)&&!u.startsWith(\"SUPABASE_INTERNAL_\")),a=!(yield*gQ(o));return yield*L.gen(function*(){let u=yield*fi(()=>EdgeRuntime.userWorkers.create({servicePath:hQ(r,o),memoryLimitMb:256,workerTimeoutMs:Number.isFinite(o2)?o2*1e3:4e5,noModuleCache:!0,noNpm:a,envVars:i,forceCreate:!0,customModuleRoot:\"\",cpuTimeSoftLimitMs:1e3,cpuTimeHardLimitMs:2e3,decoratorType:\"tc39\",maybeEntrypoint:E1(o.entrypointPath).href,context:{useReadSyncFileAPI:!0,...o.importMapPath===\"\"?{}:{importMapPath:o.importMapPath}},staticPatterns:o.staticFiles}));return yield*fi(()=>u.fetch(yQ(e,t)))}).pipe(L.retry({times:1,while:({cause:u})=>e.body===null&&tQ(u)}),L.catchTag(\"BootstrapOperationError\",({cause:u})=>gu.error(\"[functions] worker error\",u).pipe(L.andThen(L.succeed(aQ(u))))))}).pipe(L.scoped),{signal:e.signal}).then(t=>{if(Xt.isSuccess(t))return t.value;if(e.signal.aborted&&Wt.hasInterruptsOnly(t.cause))return new Response(null,{status:499});throw Wt.squash(t.cause)}),onListen:()=>{let t=Object.keys(g0).slice(0,5).map(n=>` - http://127.0.0.1:${r2}/functions/v1/${n}`);L.runSync(gu.log(`Serving functions on http://127.0.0.1:${r2}/functions/v1/${t.length?`\n${t.join(`\n`)}`:\"\"}\nUsing ${Deno.version.deno}`))},onError:()=>Na({code:Zw[500],message:\"Request failed due to an internal server error\"},500)});export{nQ as RequestErrors,cQ as extractBearerToken,yQ as prepareUserRequest};\n"; diff --git a/packages/stack/src/functions/serve-main-bundler.integration.test.ts b/packages/stack/src/functions/serve-main-bundler.integration.test.ts index f16a4149b9..8cfcc8e95c 100644 --- a/packages/stack/src/functions/serve-main-bundler.integration.test.ts +++ b/packages/stack/src/functions/serve-main-bundler.integration.test.ts @@ -3,7 +3,7 @@ import { Data, Deferred, Effect, Exit, FileSystem, Path, Schema, Stream } from " import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; -import { bundleServeMainTemplate } from "../../tests/serve-main-bundler.ts"; +import { defaultFunctionsBootstrap } from "./generated/serve-main-bundle.ts"; type ServeOptions = { readonly handler: (request: Request) => Promise; @@ -24,7 +24,7 @@ const serveSandboxed = (functionsConfig: string, createWorker: () => TestWorker) SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfig, }; - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; let serveOptions: ServeOptions | undefined; const sandbox = { Deno: { @@ -138,7 +138,7 @@ describe("stack-owned functions bootstrap", () => { let pendingCreation: Promise | undefined; const workerReady = yield* Deferred.make(); const createStarted = yield* Deferred.make(); - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; const sandbox = { Deno: { env: { @@ -297,7 +297,7 @@ describe("stack-owned functions bootstrap", () => { it.live("starts with malformed optional functions config", () => Effect.gen(function* () { - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; const envRecord: Record = { SUPABASE_INTERNAL_FUNCTIONS_CONFIG: "{" }; let serveOptions: ServeOptions | undefined; const sandbox = { @@ -373,7 +373,7 @@ describe("stack-owned functions bootstrap", () => { .setProtectedHeader({ alg: "ES256", kid: "test-key" }) .sign(privateKey), ); - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; const envRecord: Record = { SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", SUPABASE_INTERNAL_JWT_SECRET: "secret", diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 9cd40cd4dd..f6f6d05ffc 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -4,7 +4,6 @@ import { Cause, Effect, FileSystem, Layer, Ref, Schema, Stream } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { makeService } from "../Service.ts"; -import { bundleServeMainTemplate } from "../../tests/serve-main-bundler.ts"; import { makeServiceRecipe } from "./Catalog.ts"; const options = (root: string) => ({ @@ -45,13 +44,11 @@ describe("service catalog", () => { functionsRoot + "/hello/index.ts", "Deno.serve(() => Response.json({ custom: Deno.env.get('CUSTOM_ENV'), root: Deno.env.get('SUPABASE_INTERNAL_FUNCTIONS_ROOT'), port: Deno.env.get('EDGE_RUNTIME_PORT'), url: Deno.env.get('SUPABASE_URL'), db: Deno.env.get('SUPABASE_DB_URL'), jwt: Deno.env.get('SUPABASE_INTERNAL_JWT_SECRET'), jwks: Deno.env.get('SUPABASE_JWKS'), anon: Deno.env.get('SUPABASE_ANON_KEY'), service: Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'), publishable: Deno.env.get('SUPABASE_PUBLISHABLE_KEYS'), secret: Deno.env.get('SUPABASE_SECRET_KEYS') }));", ); - const bootstrap = yield* bundleServeMainTemplate; const recipe = yield* makeServiceRecipe( { service: "functions", config: { functionsRoot, - bootstrap, databaseUrl: "postgres://functions-db", apiUrl: "http://functions-api", jwtSecret: "functions-jwt-secret", @@ -169,7 +166,6 @@ describe("service catalog", () => { service: "functions", config: { functionsRoot, - bootstrap: yield* bundleServeMainTemplate, verifyJwt: false, }, }, @@ -248,7 +244,6 @@ for (const runtime of ["native", "docker"] as const) { config: { functionsRoot, filesRoot, - bootstrap: yield* bundleServeMainTemplate, jwtSecret: "test-function-jwt-with-at-least-32-characters", verifyJwt: true, env: { diff --git a/packages/stack/src/services/Functions.ts b/packages/stack/src/services/Functions.ts index e0b470ae44..4aca150ef9 100644 --- a/packages/stack/src/services/Functions.ts +++ b/packages/stack/src/services/Functions.ts @@ -3,6 +3,7 @@ import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, } from "../functions/FunctionsBootstrap.ts"; +import { defaultFunctionsBootstrap } from "../functions/generated/serve-main-bundle.ts"; import { StackIdSchema } from "../identity/StackId.ts"; import { ServiceError } from "../Service.ts"; import { serviceJwt } from "./ServiceConfig.ts"; @@ -32,7 +33,7 @@ export const Config = Schema.Struct({ functionsRoot: Schema.String, filesRoot: Schema.optionalKey(Schema.String), functions: Schema.optionalKey(Schema.Record(Schema.String, FunctionSettings)), - bootstrap: Schema.String, + bootstrap: Schema.optionalKey(Schema.String), databaseUrl: Schema.optionalKey(Schema.String), env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), apiUrl: Schema.optionalKey(Schema.String), @@ -202,7 +203,7 @@ const makeSpec = ( }), startupCommands: [], prepare: (creation) => - bootstrap.write({ content: creation.config.bootstrap }).pipe( + bootstrap.write({ content: creation.config.bootstrap ?? defaultFunctionsBootstrap }).pipe( Effect.flatMap((target) => Ref.set(functionsRoot, path.dirname(target))), Effect.mapError( (cause) => diff --git a/packages/stack/tests/serve-main-bundler.ts b/packages/stack/tests/serve-main-bundler.ts deleted file mode 100644 index 01f6bc3394..0000000000 --- a/packages/stack/tests/serve-main-bundler.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { build, stop } from "esbuild"; -import { Data, Effect } from "effect"; - -class ServeMainBundleError extends Data.TaggedError("ServeMainBundleError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -const serveMainEntrypoint = fileURLToPath( - new URL("../src/functions/serve.main.ts", import.meta.url), -); - -export const bundleServeMainTemplate = Effect.gen(function* () { - const result = yield* Effect.tryPromise({ - try: () => - build({ - entryPoints: [serveMainEntrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", - }), - catch: (cause) => - new ServeMainBundleError({ message: "Unable to bundle functions bootstrap", cause }), - }); - const output = result.outputFiles[0]?.text; - if (output === undefined) - return yield* new ServeMainBundleError({ - message: "esbuild produced no functions bootstrap output", - }); - return output; -}).pipe((buildProgram) => - Effect.uninterruptibleMask((restore) => - Effect.flatMap(Effect.exit(restore(buildProgram)), (result) => - Effect.tryPromise({ - try: () => stop(), - catch: (cause) => new ServeMainBundleError({ message: "Unable to stop esbuild", cause }), - }).pipe(Effect.andThen(result)), - ), - ), -); diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index f0a4078806..a6b1196419 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -14,7 +14,6 @@ import { import { postgres } from "../../src/Commands.ts"; import { homedir, tmpdir } from "node:os"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { bundleServeMainTemplate } from "../serve-main-bundler.ts"; import { create, type Stack } from "../../src/effect.ts"; import type { Observation } from "../../src/Rpc.ts"; import { vectorAnalyticsConfig } from "./analytics.ts"; @@ -103,7 +102,6 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => `${functionsRoot}/hello/index.ts`, "Deno.serve(async (request) => { const authorization = request.headers.get('authorization') ?? ''; const input = await request.json(); const response = await fetch(`${Deno.env.get('SUPABASE_URL')}/rest/v1/whole_stack_items?id=eq.${input.id}`, { headers: { authorization, apikey: authorization.replace('Bearer ', '') } }); return new Response(await response.text(), { status: response.status, headers: { 'content-type': 'application/json' } }); });", ); - const bootstrap = yield* bundleServeMainTemplate; const crypto = yield* Crypto.Crypto; const secret = `whole-stack-${yield* crypto.randomUUIDv4}-secret`; const locations = { @@ -170,7 +168,7 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => }, { service: "functions", - config: { functionsRoot, bootstrap, verifyJwt: true, jwtSecret: secret }, + config: { functionsRoot, verifyJwt: true, jwtSecret: secret }, endpoints: { http: endpoint("auto") }, }, { service: "studio", config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") } }, diff --git a/turbo.json b/turbo.json index fcbaf2b8c3..835b1d15aa 100644 --- a/turbo.json +++ b/turbo.json @@ -76,6 +76,10 @@ "outputs": ["src/generated/**", "scripts/openapi-source.json"], "env": ["SUPABASE_API_URL"] }, + "@supabase/stack#generate": { + "cache": false, + "outputs": ["src/functions/generated/**"] + }, "@supabase/docs#generate": { "cache": true, "dependsOn": ["supabase#build", "@supabase/config#build"], From 1ef5bc05a99829bfc60e1f48ff17b70458cedf14 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 13:59:23 +0000 Subject: [PATCH 16/71] fix(cli): prefer Docker, then Podman, for automatic stack runtime selection (#6859) `--runtime auto` picked a new stack's runtime from the platform alone: native on Linux x64/arm64 and macOS arm64, Docker elsewhere. On those platforms a new stack ran as native processes even when Docker was running. That gives up the process, filesystem, and network isolation containers provide, which matters most when several local stacks share a machine. For a new stack, auto now selects: 1. Docker, when its daemon answers `docker version` 2. Podman, when its engine answers `podman info` 3. native, on Linux x64/arm64 and macOS arm64 4. otherwise an error asking the user to start Docker or Podman Having the binary installed isn't enough: a stopped Docker Desktop or Podman machine falls through to the next option, and each probe gives up after 10 seconds. Existing stacks keep their saved runtime without probing, and explicit `--runtime` choices still never fall back. The same selection applies to `stack start`, `stack prepare`, the top-level `supabase start` alias when `experimental.stack` is enabled, project stacks created by database commands, and shadow stacks. `--runtime` now also accepts `podman`, matching the runtimes stacks already support and persist. --- apps/cli/docs/stack-commands.md | 10 +- .../command-internal/stack-local-database.ts | 36 +++- .../cli/src/command-internal/stack-runtime.ts | 95 ++++++++- apps/cli/src/command-internal/stack-shadow.ts | 18 +- .../db/shared/pgdelta-next-shadow.layer.ts | 12 +- ...elta-next-shadow.stack.integration.test.ts | 5 + .../stack/prepare/SIDE_EFFECTS.md | 5 +- .../stack/prepare/prepare.command.ts | 6 +- .../stack/prepare/prepare.errors.ts | 4 +- .../stack/prepare/prepare.handler.ts | 42 ++-- .../stack/prepare/prepare.integration.test.ts | 200 +++++++++++++++++- .../stack/stack.shared.integration.test.ts | 16 +- .../experimental/stack/stack.shared.ts | 13 +- .../experimental/stack/start/SIDE_EFFECTS.md | 8 +- .../experimental/stack/start/start.command.ts | 8 +- .../experimental/stack/start/start.handler.ts | 22 +- .../stack/start/start.integration.test.ts | 43 ++++ .../telemetry/__fixtures__/error-tags.txt | 1 + .../tests/helpers/child-process-spawner.ts | 61 +++++- 19 files changed, 536 insertions(+), 69 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index da9894b3d2..9845b95485 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -2,7 +2,7 @@ `supabase stack` manages local stacks with the new experimental runtime. It is unstable, its command interface may change, and it is excluded from the CLI compatibility promise. It is -available when the `experimental.stack` feature flag is enabled and supports both Docker and +available when the `experimental.stack` feature flag is enabled and supports Docker, Podman, and native runtimes. Native PostgreSQL requires passwords for every role except `supabase_admin`. Its local bootstrap @@ -22,6 +22,14 @@ and password-reconciliation connection uses that administrative role, so a nativ Use each command's `--help` for its available targeting and runtime options. +A new stack created with `--runtime auto` uses Docker when its daemon answers, otherwise Podman +when its engine answers, and otherwise native on Linux x64/arm64 and macOS arm64. On other +platforms without a reachable engine, the command fails and asks you to start Docker or Podman. The +selected runtime is saved with the stack and reused without probing; when auto selection skips +Docker, the command prints a notice saying so. To switch, destroy the stack or choose a different +`--stack` name. Project stacks created by database commands, and shadow stacks +created without a project stack, use the same selection. + `supabase stack prepare` downloads or pulls artifacts for the selected stack without starting services. If the target does not exist, prepare creates and registers it; the stack then appears in `supabase stack list` and can be removed with `supabase stack destroy`. Omit `--capability` to diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 7389f20a2f..4980f6fe34 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -16,9 +16,8 @@ import { readDbToml } from "./db-config.toml-read.ts"; import { StackCatalogSetup } from "./stack-catalog-setup.ts"; import { resolveExperimentalWithProjectEnv } from "./global-flags.ts"; import { applyStackMigrateAndSeed, applyStackWebhooksOnly } from "./stack-bootstrap.ts"; -import { defaultStackRuntime } from "./stack-runtime.ts"; +import { automaticRuntimeNotice, selectStackRuntime } from "./stack-runtime.ts"; import { Output } from "../shared/output/output.service.ts"; -import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import type { PgConnInput } from "./db-connection.service.ts"; type Settings = CommandSettings["Service"]; @@ -223,7 +222,6 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( const api = yield* StackApi; const settings = yield* CommandSettings; const path = yield* Path.Path; - const runtime = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const output = yield* Output; const config = yield* loadStackConfig(settings.workdir).pipe(Effect.mapError(startFailed)); @@ -232,16 +230,32 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( const existing = yield* stackForProject(api, settings, path).pipe(Effect.mapError(startFailed)); const identity = existing === undefined ? yield* config.identity.pipe(Effect.mapError(startFailed)) : undefined; + const createStack = Effect.gen(function* () { + const runtime = yield* selectStackRuntime(undefined).pipe( + Effect.mapError( + (error) => + new LocalDbRunningError({ + message: `failed to start local database: ${error.message}`, + daemonDown: true, + suggestion: error.suggestion, + }), + ), + ); + const created = yield* api + .create({ + projectRoot: settings.workdir, + stateRoot: stateRoot(settings, path), + cacheRoot: cacheRoot(settings, path), + runtime, + }) + .pipe(Effect.mapError(startFailed)); + const notice = automaticRuntimeNotice(undefined, runtime); + if (notice !== undefined) yield* output.info(notice); + return created; + }); const stack = existing === undefined - ? yield* api - .create({ - projectRoot: settings.workdir, - stateRoot: stateRoot(settings, path), - cacheRoot: cacheRoot(settings, path), - runtime: defaultStackRuntime(runtime), - }) - .pipe(Effect.mapError(startFailed)) + ? yield* createStack : yield* api .open({ id: existing.definition.id, diff --git a/apps/cli/src/command-internal/stack-runtime.ts b/apps/cli/src/command-internal/stack-runtime.ts index 05c4cb6c29..3f4834f783 100644 --- a/apps/cli/src/command-internal/stack-runtime.ts +++ b/apps/cli/src/command-internal/stack-runtime.ts @@ -1,9 +1,86 @@ -/** Selects native execution where the catalog publishes portable artifacts. */ -export const defaultStackRuntime = (runtime: { - readonly platform: string; - readonly arch: string; -}): "native" | "docker" => - (runtime.platform === "linux" && (runtime.arch === "x64" || runtime.arch === "arm64")) || - (runtime.platform === "darwin" && runtime.arch === "arm64") - ? "native" - : "docker"; +import { Data, Effect } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; + +/** Runtime that executes a local stack's services. */ +export type StackRuntime = "native" | "docker" | "podman"; + +/** Raised when automatic selection finds no reachable container engine on a host without native support. */ +export class StackRuntimeSelectionError extends Data.TaggedError("StackRuntimeSelectionError")<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} + +/** A cold Docker Desktop or Podman machine can take several seconds to answer its first request. */ +const PROBE_TIMEOUT = "10 seconds"; + +/** Each probe exits non-zero unless the engine's daemon or service answers. */ +const engineProbes = [ + { runtime: "docker", args: ["version", "--format", "{{.Server.Version}}"] }, + { runtime: "podman", args: ["info", "--format", "{{.Version.Version}}"] }, +] as const; + +const engineReachable = ( + spawner: ChildProcessSpawner["Service"], + probe: (typeof engineProbes)[number], +) => + spawner + .exitCode( + ChildProcess.make(probe.runtime, probe.args, { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + forceKillAfter: "1 second", + }), + ) + .pipe( + Effect.timeout(PROBE_TIMEOUT), + Effect.map((exitCode) => exitCode === 0), + Effect.orElseSucceed(() => false), + ); + +const nativeSupported = (platform: string, arch: string): boolean => + (platform === "linux" && (arch === "x64" || arch === "arm64")) || + (platform === "darwin" && arch === "arm64"); + +/** + * Notice for a new stack whose automatic selection skipped Docker, since that runtime is saved with + * the stack; `undefined` when the runtime was requested, saved, or Docker. + */ +export const automaticRuntimeNotice = ( + requested: StackRuntime | undefined, + selected: StackRuntime, +): string | undefined => + requested !== undefined || selected === "docker" + ? undefined + : `Docker didn't answer, so this new stack uses the ${selected === "podman" ? "Podman" : "native"} runtime, which is saved with the stack. To use Docker, start it, then run \`supabase stack destroy\` and start again, or choose a different --stack name with --runtime docker.`; + +/** + * Returns the requested or saved runtime unchanged; otherwise the first of Docker, Podman, or + * native that is usable on this host. + */ +export const selectStackRuntime = Effect.fn("StackRuntime.select")(function* ( + requested: StackRuntime | undefined, +) { + if (requested !== undefined) return requested; + const spawner = yield* ChildProcessSpawner; + for (const probe of engineProbes) { + if (yield* engineReachable(spawner, probe)) return probe.runtime; + } + const { platform, arch } = yield* RuntimeInfo; + if (nativeSupported(platform, arch)) return "native"; + return yield* new StackRuntimeSelectionError({ + message: `Neither Docker nor Podman is reachable, and native stacks are not supported on ${platform}/${arch}.`, + suggestion: "Start Docker or Podman, then rerun the command.", + }); +}); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index a33137d272..377dba0b01 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -6,7 +6,7 @@ import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { skippedRuntimeCleanupWarning, StackApi } from "./stack-api.ts"; import { StackCatalogSetup } from "./stack-catalog-setup.ts"; import { stackProjectRuntime } from "./stack-local-database.ts"; -import { defaultStackRuntime } from "./stack-runtime.ts"; +import { selectStackRuntime } from "./stack-runtime.ts"; import { parseConnectionString } from "./db-config.parse.ts"; import { toPostgresURL } from "./postgres-url.ts"; import { @@ -49,13 +49,25 @@ const shadowError = (cause: { readonly message: string }) => const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +/** Selects the shadow runtime: the project stack's saved runtime, otherwise automatic selection. */ +export const stackShadowRuntime = Effect.gen(function* () { + return yield* selectStackRuntime(yield* stackProjectRuntime).pipe( + Effect.mapError( + (error) => + new ShadowDbError({ + message: `${error.message} ${error.suggestion}`, + reason: "docker_daemon", + }), + ), + ); +}); + const acquireNamespace = Effect.fn("StackShadow.acquireNamespace")(function* (opts: ShadowOptions) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const api = yield* StackApi; const settings = yield* CommandSettings; - const runtimeInfo = yield* RuntimeInfo; - const runtime = opts.runtime ?? (yield* stackProjectRuntime) ?? defaultStackRuntime(runtimeInfo); + const runtime = opts.runtime ?? (yield* stackShadowRuntime); const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-shadow-" }); const stack = yield* api.create({ projectRoot: root, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index c0d2c3d33a..a0b19f1a3f 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -52,6 +52,7 @@ import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { stackAcquireShadowDatabase, + stackShadowRuntime, stackMigrateShadow, } from "../../../command-internal/stack-shadow.ts"; import { StackApi } from "../../../command-internal/stack-api.ts"; @@ -290,10 +291,15 @@ export const pgDeltaNextShadowLayer = Layer.effect( } satisfies ProvisionedDeclarativeShadow; }).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError)); + // Both shadows of one plan share a runtime, so the engines are probed at most once. + const shadowRuntime = yield* Effect.cached(stackShadowRuntime); const stackAcquire = (input: NativeShadowInput, opts: ShadowCacheOpts) => - stackAcquireShadowDatabase(input.base, { - ...(opts.webhooks === undefined ? {} : { webhooks: opts.webhooks }), - ...(opts.bypassCache === true ? { bypassCache: true } : {}), + Effect.gen(function* () { + return yield* stackAcquireShadowDatabase(input.base, { + runtime: yield* shadowRuntime, + ...(opts.webhooks === undefined ? {} : { webhooks: opts.webhooks }), + ...(opts.bypassCache === true ? { bypassCache: true } : {}), + }); }); const stackProvisionMigrations = (input: NativeShadowInput, opts: ShadowCacheOpts) => diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts index dbaa444078..8073f19f52 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts @@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { Effect, FileSystem, Layer, Option } from "effect"; import { mockCommandSettings, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { containerEngineSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; @@ -91,6 +92,7 @@ describe("pg-delta next stack shadow provisioning", () => { ); const stateRoot = `${root}/stacks`; + const engines = containerEngineSpawner({ docker: "missing", podman: "missing" }); const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); const output = mockOutput().layer; const apiLayer = stackApiLayer.pipe( @@ -109,6 +111,8 @@ describe("pg-delta next stack shadow provisioning", () => { Layer.provide(Layer.succeed(ExperimentalFlag, false)), Layer.provide(Layer.succeed(NetworkIdFlag, Option.none())), Layer.provide(Layer.succeed(CliArgs, { args: [] })), + // Without a reachable container engine, automatic selection keeps the shadows native. + Layer.provide(engines.hidingLayer), Layer.provide(BunServices.layer), ); const services = Layer.mergeAll( @@ -151,6 +155,7 @@ describe("pg-delta next stack shadow provisioning", () => { ).toEqual([{ table_name: null }]); const api = yield* StackApi; expect(yield* api.discover({ stateRoot })).toHaveLength(2); + expect(engines.spawned.map(({ command }) => command)).toEqual(["docker", "podman"]); return plan; }).pipe(Effect.provide(services)), ); diff --git a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md index 1a41ef9324..4d51539261 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md @@ -34,8 +34,9 @@ caching, preparation, and cancellation; the CLI owns the temporary instance clea ## Flags and output -`--stack` and `--stack-id` are mutually exclusive. `--runtime` defaults to `auto` for new stacks; -existing stacks reuse their persisted runtime, and an explicit mismatch fails. With no +`--stack` and `--stack-id` are mutually exclusive. `--runtime` defaults to `auto` for new stacks, +which selects the same runtime as `stack start` (Docker, then Podman, then native); +existing stacks reuse their persisted runtime without probing, and an explicit mismatch fails. With no `--capability`, every enabled creation from the effective project configuration is prepared. Repeated `--capability` includes each capability's configured companion services (Storage/Imgproxy, Studio/Pgmeta, Analytics/Vector); requesting a disabled service fails before instance preparation. The legacy `-o/--output` flag is rejected; use diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index 3c5ac534c5..0e728fb4e3 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -11,8 +11,10 @@ const config = { Flag.withDescription("Open an existing stack by id."), Flag.optional, ), - runtime: Flag.choice("runtime", ["auto", "docker", "native"] as const).pipe( - Flag.withDescription("Runtime to use for a new stack."), + runtime: Flag.choice("runtime", ["auto", "docker", "podman", "native"] as const).pipe( + Flag.withDescription( + "Runtime to use for a new stack. auto selects Docker, then Podman, then native, based on what is available.", + ), Flag.withDefault("auto" as const), ), capability: Flag.atMost( diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts index 59c97f1641..8fea9be28b 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -7,7 +7,7 @@ import { } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandPrepareError extends Data.TaggedError("ExperimentalStackPrepareError")<{ - readonly reason: "flags" | "invalid-config" | "artifact" | "lifecycle" | "unknown"; + readonly reason: "flags" | "invalid-config" | "runtime" | "artifact" | "lifecycle" | "unknown"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -19,6 +19,8 @@ export class StackCommandPrepareError extends Data.TaggedError("ExperimentalStac case "invalid-config": case "lifecycle": return actionability.invalidConfig; + case "runtime": + return actionability.dockerNotRunning; case "artifact": return actionability.externalNetwork; case "unknown": diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts index 19f437d2d7..569220115b 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -2,7 +2,6 @@ import { Cause, Effect, Exit, Option, Path } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; import { @@ -15,7 +14,10 @@ import { } from "../stack.shared.ts"; import type { StackPrepareFlags } from "./prepare.command.ts"; import { StackCommandPrepareError, stackPrepareError } from "./prepare.errors.ts"; -import { defaultStackRuntime } from "../../../../command-internal/stack-runtime.ts"; +import { + automaticRuntimeNotice, + selectStackRuntime, +} from "../../../../command-internal/stack-runtime.ts"; type PreparedCapability = { readonly capability: string; @@ -52,7 +54,6 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( const output = yield* Output; const settings = yield* CommandSettings; const path = yield* Path.Path; - const runtime = yield* RuntimeInfo; const resolver = yield* StackTargetResolver; const api = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); @@ -81,17 +82,34 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( ); const stateRoot = path.join(settings.supabaseHome, "stacks"); const cacheRoot = path.join(settings.supabaseHome, "cache", "stack"); + const createStack = Effect.gen(function* () { + const runtime = yield* selectStackRuntime(target.runtime).pipe( + Effect.mapError( + (error) => + new StackCommandPrepareError({ + reason: "runtime", + message: error.message, + suggestion: error.suggestion, + cause: error, + }), + ), + ); + const created = yield* api + .create({ + projectRoot: target.projectRoot, + stateRoot, + cacheRoot, + runtime, + ...(target.name === undefined ? {} : { name: target.name }), + }) + .pipe(Effect.mapError(stackPrepareError)); + const notice = automaticRuntimeNotice(target.runtime, runtime); + if (notice !== undefined) yield* output.info(notice); + return created; + }); const stack = target.id === undefined - ? yield* api - .create({ - projectRoot: target.projectRoot, - stateRoot, - cacheRoot, - runtime: target.runtime ?? defaultStackRuntime(runtime), - ...(target.name === undefined ? {} : { name: target.name }), - }) - .pipe(Effect.mapError(stackPrepareError)) + ? yield* createStack : yield* api .open({ id: target.id, stateRoot, cacheRoot }) .pipe(Effect.mapError(stackPrepareError)); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index 9185ce22f4..b0b97acf6c 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -8,11 +8,20 @@ import { type ServiceInstances, } from "@supabase/stack/effect"; import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer.ts"; +import type { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + StackRuntimeSelectionError, + type StackRuntime, +} from "../../../../command-internal/stack-runtime.ts"; +import { + containerEngineSpawner, + type ContainerEngineState, +} from "../../../../../tests/helpers/child-process-spawner.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { StackApi, StackTargetResolver } from "../stack.shared.ts"; import { stackPrepare } from "./prepare.handler.ts"; import type { StackPrepareFlags } from "./prepare.command.ts"; @@ -37,7 +46,23 @@ const makeProject = (config: string) => return root; }); -const makeFixture = (root: string, failPreparation = false) => { +interface FixtureOptions { + readonly failPreparation?: boolean; + readonly savedRuntime?: StackRuntime; + readonly engines?: { + readonly docker: ContainerEngineState; + readonly podman: ContainerEngineState; + }; + readonly runtimeInfo?: Layer.Layer; +} + +const makeFixture = (root: string, options: FixtureOptions = {}) => { + const { failPreparation = false } = options; + const engines = containerEngineSpawner( + options.engines ?? { docker: "missing", podman: "missing" }, + ); + const createdRuntimes: Array = []; + let openCount = 0; let prepareCount = 0; let startCount = 0; let destroyCount = 0; @@ -137,22 +162,56 @@ const makeFixture = (root: string, failPreparation = false) => { const telemetry = mockTelemetryStateTracked(); const layer = Layer.mergeAll( BunServices.layer, - runtimeInfoLayer, + options.runtimeInfo ?? runtimeInfoLayer, + engines.layer, mockCommandSettings({ workdir: root, supabaseHome: root }), output.layer, telemetry.layer, Layer.succeed(StackTargetResolver, { - resolve: () => Effect.succeed({ projectRoot: root, hostRunning: false }), + resolve: (input) => + Effect.succeed({ + projectRoot: root, + hostRunning: false, + ...(options.savedRuntime === undefined + ? input.runtime === "auto" + ? {} + : { runtime: input.runtime } + : { id, runtime: options.savedRuntime }), + }), }), Layer.succeed(StackApi, { - create: () => Effect.succeed(stack), - open: () => Effect.succeed(stack), + create: (input) => + Effect.sync(() => { + createdRuntimes.push(input.runtime); + return stack; + }), + open: () => + Effect.sync(() => { + openCount += 1; + return stack; + }), discover: () => Effect.succeed([]), resolveIdentity: () => Effect.die("unused"), }), ); return { layer, + get createdRuntimes() { + return createdRuntimes; + }, + get openCount() { + return openCount; + }, + get runtimeNotices() { + return output.messages + .filter( + ({ type, message }) => type === "info" && message?.startsWith("Docker didn't answer"), + ) + .map(({ message }) => message); + }, + get probes() { + return engines.spawned.map(({ command }) => command); + }, get prepareCount() { return prepareCount; }, @@ -215,7 +274,7 @@ describe("stack prepare", () => { it.live("removes its temporary instance after a preparation failure", () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { - const fixture = makeFixture(root, true); + const fixture = makeFixture(root, { failPreparation: true }); return stackPrepare(flags()).pipe( Effect.provide(fixture.layer), Effect.flip, @@ -274,3 +333,130 @@ describe("stack prepare", () => { ), ); }); + +describe("stack prepare automatic runtime selection", () => { + const selectRuntime = (options: FixtureOptions) => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, options); + return stackPrepare(flags({ runtime: "auto" })).pipe( + Effect.provide(fixture.layer), + Effect.as(fixture), + ); + }), + Effect.provide(BunServices.layer), + ); + + it.live("creates a Docker stack when the Docker engine answers", () => + selectRuntime({ engines: { docker: "running", podman: "running" } }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["docker"]); + expect(fixture.probes).toEqual(["docker"]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ), + ); + + it.live("creates a Podman stack when Docker is not installed and Podman answers", () => + selectRuntime({ + engines: { docker: "missing", podman: "running" }, + runtimeInfo: mockRuntimeInfo({ platform: "linux", arch: "x64" }), + }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["podman"]); + expect(fixture.probes).toEqual(["docker", "podman"]); + expect(fixture.runtimeNotices).toHaveLength(1); + expect(fixture.runtimeNotices[0]).toContain("uses the Podman runtime"); + expect(fixture.runtimeNotices[0]).toContain("supabase stack destroy"); + }), + ), + ), + ); + + it.live( + "creates a native stack on Linux when the Docker daemon is down and Podman is absent", + () => + selectRuntime({ + engines: { docker: "stopped", podman: "missing" }, + runtimeInfo: mockRuntimeInfo({ platform: "linux", arch: "x64" }), + }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["native"]); + expect(fixture.probes).toEqual(["docker", "podman"]); + expect(fixture.runtimeNotices).toHaveLength(1); + expect(fixture.runtimeNotices[0]).toContain("uses the native runtime"); + }), + ), + ), + ); + + for (const [platform, arch] of [ + ["win32", "x64"], + ["darwin", "x64"], + ] as const) { + it.live(`fails without creating a stack when no engine answers on ${platform}/${arch}`, () => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, { + engines: { docker: "stopped", podman: "stopped" }, + runtimeInfo: mockRuntimeInfo({ platform, arch }), + }); + return stackPrepare(flags({ runtime: "auto" })).pipe( + Effect.provide(fixture.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(StackCommandPrepareError); + expect(error.reason).toBe("runtime"); + expect(error.cause).toBeInstanceOf(StackRuntimeSelectionError); + expect(error.message).toContain(`not supported on ${platform}/${arch}`); + expect(error.suggestion).toBe("Start Docker or Podman, then rerun the command."); + expect(fixture.createdRuntimes).toEqual([]); + }), + ), + ); + }), + Effect.provide(BunServices.layer), + ), + ); + } + + it.live("reuses a saved runtime without probing any engine", () => + selectRuntime({ + savedRuntime: "podman", + engines: { docker: "running", podman: "running" }, + }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.openCount).toBe(1); + expect(fixture.createdRuntimes).toEqual([]); + expect(fixture.probes).toEqual([]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ), + ); + + it.live("honors an explicit runtime without probing any engine", () => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, { engines: { docker: "running", podman: "running" } }); + return stackPrepare(flags({ runtime: "podman" })).pipe( + Effect.provide(fixture.layer), + Effect.tap(() => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["podman"]); + expect(fixture.probes).toEqual([]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ); + }), + Effect.provide(BunServices.layer), + ), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index 603bb49d7c..d23336c08c 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -15,7 +15,7 @@ type TargetInput = { readonly projectRoot: string; readonly name?: string; readonly id?: string; - readonly runtime: "auto" | "docker" | "native"; + readonly runtime: "auto" | "docker" | "podman" | "native"; }; type DiscoveredStack = Effect.Success< @@ -119,6 +119,20 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("carries an explicit Podman runtime onto a new stack target", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-podman-" }); + const target = yield* resolveTarget(resolverLayer([], root, path.join(root, ".supabase")), { + projectRoot: root, + runtime: "podman", + }); + expect(target).toMatchObject({ runtime: "podman", hostRunning: false }); + expect(target.id).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("selects a saved stack by the package identity tuple", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index b3c19fb621..a1100e561f 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -11,6 +11,7 @@ import { StackApi, stackApiLayer, } from "../../../command-internal/stack-api.ts"; +import type { StackRuntime } from "../../../command-internal/stack-runtime.ts"; export { skippedRuntimeCleanupWarning, StackApi, stackApiLayer }; @@ -22,7 +23,7 @@ export interface StackTarget { readonly projectRoot: string; readonly id?: StackId; readonly name?: string; - readonly runtime?: "native" | "docker" | "podman"; + readonly runtime?: StackRuntime; readonly hostRunning: boolean; } @@ -42,7 +43,7 @@ interface StackTargetResolverShape { readonly projectRoot: string; readonly name?: string; readonly id?: string; - readonly runtime: "auto" | "docker" | "native"; + readonly runtime: "auto" | StackRuntime; }) => Effect.Effect; } @@ -88,10 +89,8 @@ export const rejectStackOutput = ( ) : Effect.void; -const runtimeForFlag = ( - runtime: "auto" | "docker" | "native", -): StackTarget["runtime"] | undefined => - runtime === "auto" ? undefined : runtime === "docker" ? "docker" : "native"; +const runtimeForFlag = (runtime: "auto" | StackRuntime): StackTarget["runtime"] => + runtime === "auto" ? undefined : runtime; const runtimeMatches = ( saved: StackTarget["runtime"], @@ -109,7 +108,7 @@ export const stackTargetResolverLayer = Layer.effect( readonly projectRoot: string; readonly name?: string; readonly id?: string; - readonly runtime: "auto" | "docker" | "native"; + readonly runtime: "auto" | StackRuntime; }) { const id = input.id === undefined ? undefined : yield* validateStackId(input.id); const requestedRuntime = runtimeForFlag(input.runtime); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 942f7c1fcb..2e7404afb9 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -33,8 +33,12 @@ Secrets needed by enabled services are passed to the runtime. State and service use `$SUPABASE_HOME/cache/stack`. Storage files use the caller-owned project directory `supabase/.temp/stack-uploads//`. Functions preparation may build the project's source. -For a new stack, `--runtime auto` selects native on Linux x64/arm64 and macOS arm64, and Docker -elsewhere. An existing stack keeps its saved runtime. Explicit runtime selection has no fallback. +For a new stack, `--runtime auto` selects Docker when `docker version` reaches its daemon, then +Podman when `podman info` reaches its engine, then native on Linux x64/arm64 and macOS arm64. Each +probe is bounded by 10 seconds. Without a reachable engine on other platforms, the command fails and +asks the user to start Docker or Podman. When auto selection skips Docker, an info line names the +saved Podman or native runtime and how to switch to Docker. An existing stack keeps its saved +runtime and runs no probe. Explicit `--runtime docker`, `podman`, or `native` has no fallback. Native startup refuses root because PostgreSQL `initdb` cannot run as root, unless a Claude Code sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs as that user: the CLI chowns the instance data, root key, socket directory, and the cached bundle's diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index 3b81180aed..4f9df6a258 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -20,8 +20,10 @@ const config = { Flag.withDescription("Open an existing stack by id."), Flag.optional, ), - runtime: Flag.choice("runtime", ["auto", "docker", "native"] as const).pipe( - Flag.withDescription("Runtime to use for a new stack."), + runtime: Flag.choice("runtime", ["auto", "docker", "podman", "native"] as const).pipe( + Flag.withDescription( + "Runtime to use for a new stack. auto selects Docker, then Podman, then native, based on what is available.", + ), Flag.withDefault("auto" as const), ), preparation: Flag.choice("preparation", ["background", "on-demand"] as const).pipe( @@ -42,7 +44,7 @@ export const stackStartCommand = Command.make("start", config).pipe( Command.withDescription( "Create or resume a managed local Supabase stack using supabase/config.toml when present. " + "Without a config file, default settings are used and no file is created. " + - "For a new stack, auto selects native on supported platforms and Docker elsewhere; the choice is persisted for that stack. " + + "For a new stack, auto selects Docker when its engine is reachable, then Podman, then native on supported platforms; the choice is persisted for that stack. " + "Explicit runtime choices are honored. Values support explicit env(NAME) references and automatic SUPABASE_* overrides.", ), Command.withShortDescription("Start a managed local stack"), diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 0406fde085..d2ba8b57f1 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,6 +1,9 @@ import { endpointReports } from "../stack-endpoints.format.ts"; import { readStackFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; -import { defaultStackRuntime } from "../../../../command-internal/stack-runtime.ts"; +import { + automaticRuntimeNotice, + selectStackRuntime, +} from "../../../../command-internal/stack-runtime.ts"; import { Effect, Equal, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; import { resolveNativePostgresUser, @@ -18,7 +21,6 @@ import { } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { readDbToml } from "../../../../command-internal/db-config.toml-read.ts"; import { StackCatalogSetup } from "../../../../command-internal/stack-catalog-setup.ts"; import { @@ -253,7 +255,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const body = Effect.gen(function* () { const output = yield* Output; const settings = yield* CommandSettings; - const runtime = yield* RuntimeInfo; const resolver = yield* StackTargetResolver; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); @@ -273,7 +274,17 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags runtime: flags.runtime, }) .pipe(Effect.mapError(mapTargetError)); - const selectedRuntime = target.runtime ?? defaultStackRuntime(runtime); + const selectedRuntime = yield* selectStackRuntime(target.runtime).pipe( + Effect.mapError( + (error) => + new StackCommandStartError({ + reason: "runtime", + message: error.message, + suggestion: error.suggestion, + cause: error, + }), + ), + ); const postgresUser = yield* resolveNativePostgresUser(selectedRuntime); const ensurePostgresUser = postgresUser._tag === "Unavailable" @@ -318,6 +329,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ), ).pipe(Effect.mapError(stackError)); + const runtimeNotice = + target.id === undefined ? automaticRuntimeNotice(target.runtime, selectedRuntime) : undefined; + if (runtimeNotice !== undefined) yield* output.info(runtimeNotice); const existingServices = yield* stack.services.list.pipe(Effect.mapError(stackError)); const composition = yield* stack.composition.describe.pipe(Effect.mapError(stackError)); const currentInstances = yield* Effect.forEach(composition.members, ({ id }) => diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 2cfb8574f6..b04b38ca77 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -22,6 +22,7 @@ import { mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { containerEngineSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { DbConnection, type DbSession, @@ -943,6 +944,48 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("reports the saved runtime when automatic selection creates a Podman stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-auto-podman-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "auto-podman"\n[edge_runtime]\nenabled = false\n', + ); + const output = mockOutput(); + const engines = containerEngineSpawner({ docker: "stopped", podman: "running" }); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => Effect.succeed({ projectRoot: root, hostRunning: false }), + }); + yield* stackStart({ + ...flags([ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ]), + runtime: "auto", + }).pipe( + Effect.provide( + Layer.mergeAll(layers(root, fakeStack(), output, false), target, engines.layer), + ), + ); + expect(engines.spawned.map(({ command }) => command)).toEqual(["docker", "podman"]); + expect(output.messages).toContainEqual({ + type: "info", + message: expect.stringContaining( + "Docker didn't answer, so this new stack uses the Podman runtime", + ), + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("leaves no stack registered when a new stack's owner cannot reach Docker", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 6c8d8301ff..49daf10d9e 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -529,6 +529,7 @@ StackConfigError StackFunctionsEnvError StackNativeEngineError StackRoutingError +StackRuntimeSelectionError StackRuntimeUnavailableError StackStorageCapabilityError StackStorageUnavailableError diff --git a/apps/cli/tests/helpers/child-process-spawner.ts b/apps/cli/tests/helpers/child-process-spawner.ts index 13922bd1bf..7dc2ad5a0e 100644 --- a/apps/cli/tests/helpers/child-process-spawner.ts +++ b/apps/cli/tests/helpers/child-process-spawner.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect"; +import { Deferred, Effect, Layer, PlatformError, Predicate, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; interface SpawnRecord { @@ -102,3 +102,62 @@ export function mockChildProcessSpawner( }, }; } + +/** How a host's container engine answers: absent from PATH, installed with its daemon down, or serving. */ +export type ContainerEngineState = "missing" | "stopped" | "running"; + +/** + * Spawner for a host whose `docker` and `podman` commands behave as `engines` describe. Other + * commands fail with `NotFound`, or run on the real spawner through `hidingLayer`. + */ +export function containerEngineSpawner(engines: { + readonly docker: ContainerEngineState; + readonly podman: ContainerEngineState; +}) { + const spawned: SpawnRecord[] = []; + const notFound = (command: string) => + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: command, + }); + const spawner = (delegate?: ChildProcessSpawner.ChildProcessSpawner["Service"]) => + ChildProcessSpawner.make((command) => { + const cmd = Predicate.isTagged(command, "StandardCommand") ? command.command : ""; + const args = Predicate.isTagged(command, "StandardCommand") ? command.args : []; + if (cmd !== "docker" && cmd !== "podman") + return delegate === undefined ? Effect.fail(notFound(cmd)) : delegate.spawn(command); + spawned.push({ command: cmd, args }); + const state = engines[cmd]; + if (state === "missing") return Effect.fail(notFound(cmd)); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(2000 + spawned.length), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(state === "running" ? 0 : 1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + return { + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner()), + /** Wraps the provided real spawner so only the container engine commands are faked. */ + hidingLayer: Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + return spawner(yield* ChildProcessSpawner.ChildProcessSpawner); + }), + ), + get spawned() { + return spawned; + }, + }; +} From 8e48a07f3c6781ae259e2f893d5c34a979839cb6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 28 Sep 2026 14:30:22 +0000 Subject: [PATCH 17/71] refactor(stack): own composition policy and stack lookup in the package (#6840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The CLI re-derived policy the package owns: - It hand-copied the composition's binding table and the eager/lazy rules. - It passed placeholder URLs for inputs the composition overwrites. - It found stacks by listing every stack and matching identity fields. - It ran database initialisation in four places. Starting a stopped stack with changed configuration was rejected with a suggestion to destroy it. ## Change - `find` reads one stack by identity or id. CLI target resolution uses it, so an unreadable state file surfaces as an error instead of "not found". - Composition-bound inputs are optional. Required inputs are validated before a service stops, so an invalid restart leaves it running. Placeholder URLs are gone. - `composition.plan` reports, per member, whether a requested configuration is unchanged, changed or incompatible. An `eager` option replaces the CLI's copy of the activation policy, and `stack status` reports drift from the plan. - Starting a stack whose owner is not running applies changed configuration. Only incompatible changes are rejected: endpoints, top-level versions and the database major version. - One stack database initialisation routine is shared by `stack start`, `db start`, `db reset` and schema shadows. - The internal artifact entrypoints are merged, the auth mapper's pass-throughs are type-checked, and the composition `identity` option is renamed to `keys`. ## Stack Part 7 of 8 of the stack package simplification, based on #6839. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package ← this PR 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API --------- Co-authored-by: Claude Opus 5.5 --- .../bundled-postgres-client.ts | 2 +- .../db-bootstrap/reset-local-database.ts | 80 ++-- apps/cli/src/command-internal/stack-api.ts | 21 +- .../src/command-internal/stack-auth-config.ts | 44 +- .../src/command-internal/stack-bootstrap.ts | 112 +++-- .../stack-catalog-setup.integration.test.ts | 5 +- .../command-internal/stack-catalog-setup.ts | 9 + apps/cli/src/command-internal/stack-config.ts | 32 +- .../command-internal/stack-functions-env.ts | 12 + .../command-internal/stack-local-database.ts | 86 ++-- .../command-internal/stack-shadow-cache.ts | 2 +- apps/cli/src/command-internal/stack-shadow.ts | 5 +- .../test-db.integration.test.ts | 2 +- .../commands/db/dump/dump.integration.test.ts | 16 +- .../db/reset/reset.integration.test.ts | 45 +- .../commands/db/reset/reset.stack.e2e.test.ts | 2 - .../generate/generate.integration.test.ts | 13 +- .../declarative/sync/sync.integration.test.ts | 13 +- .../db/start/start.integration.test.ts | 57 ++- .../stack/destroy/destroy.integration.test.ts | 1 - .../experimental/stack/logs/logs.handler.ts | 5 +- .../stack/prepare/prepare.integration.test.ts | 3 +- .../stack/stack-config.integration.test.ts | 42 +- .../stack-forwarding.integration.test.ts | 27 +- .../stack/stack.shared.integration.test.ts | 214 +++++---- .../experimental/stack/stack.shared.ts | 83 ++-- .../experimental/stack/start/SIDE_EFFECTS.md | 21 +- .../experimental/stack/start/start.handler.ts | 347 ++++---------- .../stack/start/start.integration.test.ts | 113 ++++- .../start/start.native.integration.test.ts | 65 ++- .../experimental/stack/status/SIDE_EFFECTS.md | 17 +- .../stack/status/status.handler.ts | 143 ++---- .../stack/status/status.integration.test.ts | 125 +++-- .../experimental/stack/stop/SIDE_EFFECTS.md | 10 +- .../experimental/stack/stop/stop.handler.ts | 57 ++- .../stack/stop/stop.integration.test.ts | 10 +- .../functions/serve/serve.stack.e2e.test.ts | 2 +- .../serve/serve.stack.integration.test.ts | 17 +- .../commands/services/services-local-stack.ts | 2 +- apps/cli/tests/helpers/storage.ts | 14 +- apps/cli/tests/helpers/unused-stack.ts | 2 +- packages/stack/ARCHITECTURE.md | 6 +- packages/stack/README.md | 12 +- packages/stack/package.json | 6 +- packages/stack/src/Owner.integration.test.ts | 82 +++- packages/stack/src/Owner.ts | 18 +- packages/stack/src/Rpc.ts | 3 +- .../Supabase.native.integration.test.ts | 13 +- packages/stack/src/composition/Supabase.ts | 443 ++++++++++++------ .../stack/src/credentials.integration.test.ts | 2 +- packages/stack/src/effect.integration.test.ts | 262 ++++++++++- packages/stack/src/effect.ts | 59 ++- packages/stack/src/host/Credentials.ts | 124 ++--- packages/stack/src/host/Endpoints.ts | 6 +- packages/stack/src/index.ts | 25 +- .../{postgres-artifact.ts => artifacts.ts} | 3 +- .../stack/src/internal/service-catalog.ts | 1 - packages/stack/src/services/Analytics.ts | 15 +- packages/stack/src/services/Auth.ts | 143 +++--- packages/stack/src/services/Catalog.ts | 26 + packages/stack/src/services/Pgmeta.ts | 15 +- packages/stack/src/services/Pooler.ts | 9 +- packages/stack/src/services/Realtime.ts | 15 +- packages/stack/src/services/Rest.ts | 43 +- packages/stack/src/services/ServiceConfig.ts | 29 +- .../src/services/ServiceConfig.unit.test.ts | 20 +- packages/stack/src/services/Storage.ts | 15 +- packages/stack/src/services/Vector.ts | 30 +- packages/stack/tests/whole-stack/fixture.ts | 15 +- 69 files changed, 2002 insertions(+), 1316 deletions(-) rename packages/stack/src/internal/{postgres-artifact.ts => artifacts.ts} (50%) delete mode 100644 packages/stack/src/internal/service-catalog.ts diff --git a/apps/cli/src/command-internal/bundled-postgres-client.ts b/apps/cli/src/command-internal/bundled-postgres-client.ts index 78668af596..df9476e09b 100644 --- a/apps/cli/src/command-internal/bundled-postgres-client.ts +++ b/apps/cli/src/command-internal/bundled-postgres-client.ts @@ -17,7 +17,7 @@ import { postgresVersion, prepareNativeArtifact, resolveArtifact, -} from "@supabase/stack/internal/postgres-artifact"; +} from "@supabase/stack/internal/artifacts"; import { DockerRun, type DockerRunOpts } from "./docker-run.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index cdf1bdeed8..03472f191c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -29,9 +29,7 @@ import { import { aqua, yellow } from "../colors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts"; -import { DbConnection } from "../db-connection.service.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; -import { migrateAndSeed } from "../migrate-and-seed.ts"; import { hasConfiguredBuckets, seedBucketsRun } from "../seed-buckets.ts"; import { awaitStorageReady } from "./await-storage-ready.ts"; import { resolveResetSeedConfig } from "./db-setup.ts"; @@ -39,7 +37,7 @@ import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; import { currentStackBackend } from "../stack-backend.ts"; -import { stackLocalDatabaseConn, stackOpenReadyProject } from "../stack-local-database.ts"; +import { stackOpenReadyProject } from "../stack-local-database.ts"; import { classifyStorageCapability, describeStorageCapability, @@ -48,7 +46,12 @@ import { stackStorageEndpointFor, } from "../stack-storage.ts"; import { loadStackConfig } from "../stack-config.ts"; -import { StackCatalogSetup } from "../stack-catalog-setup.ts"; +import { catalogDatabaseServices, StackCatalogSetup } from "../stack-catalog-setup.ts"; +import { + initializeStackDatabase, + projectCatalogOverlay, + StackBootstrapError, +} from "../stack-bootstrap.ts"; /** The local database container is not running. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ @@ -145,18 +148,13 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu ), ), ); - const composed = members.flatMap((member) => - member.service === "auth" || member.service === "storage" || member.service === "realtime" - ? [member.service] - : [], - ); // A database-only composition is the `db start` overlay, which provisions schemas from config; // a full stack keeps its saved members so `stack start --exclude` choices hold. const databaseServices = members.every((member) => member.service === "database") ? (["auth", "realtime", "storage"] as const).filter( (service) => config.source[service].enabled, ) - : composed; + : catalogDatabaseServices(members); yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); yield* opened.value.stack.composition.stop.pipe( Effect.mapError((cause) => resetFailed(`failed to stop local stack: ${cause.message}`)), @@ -170,44 +168,30 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu yield* opened.value.database.ready.pipe( Effect.mapError((cause) => resetFailed(`failed to ready local database: ${cause.message}`)), ); - yield* catalog.value - .apply({ - target: { - stack: opened.value.stack, - database: opened.value.database, - databaseServices, - }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir, - }, - }) - .pipe(Effect.mapError((cause) => resetFailed(cause.message))); - const dbConn = yield* DbConnection; - const conn = yield* stackLocalDatabaseConn.pipe( - Effect.mapError((cause) => new ResetLocalDbNotRunningError({ message: cause.message })), - ); - yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* dbConn - .connect(conn, { isLocal: true, dnsResolver: "native" }) - .pipe( - Effect.mapError((cause) => - resetFailed(`failed to connect after reset: ${cause.message}`), - ), - ); - yield* migrateAndSeed(session, fs, path, workdir, input.version, { - migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), - experimental, - pgDeltaEnabled: toml.pgDelta.enabled, - schemaPaths: toml.schemaPaths, - localDatabaseWebhooksEnabled: toml.webhooksEnabled, - }).pipe(Effect.mapError((cause) => resetFailed(cause.message))); - }), + yield* initializeStackDatabase({ + target: { + stack: opened.value.stack, + database: opened.value.database, + databaseServices, + }, + overlay: projectCatalogOverlay(toml, workdir), + migrations: { + workdir, + toml: { ...toml, seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path) }, + experimental, + version: input.version, + }, + }).pipe( + Effect.provideService(StackCatalogSetup, catalog.value), + Effect.mapError((cause) => + !(cause instanceof StackBootstrapError) + ? resetFailed(cause.message) + : cause.reason === "unavailable" + ? new ResetLocalDbNotRunningError({ message: cause.message }) + : cause.reason === "connect" + ? resetFailed(`failed to connect after reset: ${cause.message}`) + : resetFailed(cause.message), + ), ); yield* opened.value.stack.composition.start.pipe( Effect.mapError((cause) => diff --git a/apps/cli/src/command-internal/stack-api.ts b/apps/cli/src/command-internal/stack-api.ts index db8975ebd3..4c09eff029 100644 --- a/apps/cli/src/command-internal/stack-api.ts +++ b/apps/cli/src/command-internal/stack-api.ts @@ -1,21 +1,21 @@ -import { Context, Crypto, Effect, FileSystem, Layer, Path, Scope } from "effect"; +import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Scope } from "effect"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { create, discover, + find, open, type CreateOptions, type DestroyResult, + type FindOptions, + type FoundStack, type OpenOptions, type Stack, + type StackError, } from "@supabase/stack/effect"; -import { resolveStackIdentity } from "@supabase/stack/internal/identity"; type DiscoverResult = Effect.Success>; -type StackError = Effect.Error>; -type IdentityResult = Effect.Success>; -type IdentityError = Effect.Error>; /** Operations used by the CLI stack boundary; a handle lasts until its scope closes. */ export class StackApi extends Context.Service< @@ -26,9 +26,7 @@ export class StackApi extends Context.Service< readonly discover: ( options: Parameters[0], ) => Effect.Effect; - readonly resolveIdentity: ( - options: Parameters[0], - ) => Effect.Effect; + readonly find: (options: FindOptions) => Effect.Effect, StackError>; } >()("supabase/stack/StackApi") {} @@ -57,15 +55,14 @@ export const stackApiLayer = Layer.effect( const discoverStacks = Effect.fn("StackApi.discover")( (options: Parameters[0]) => provideServices(discover(options)), ); - const resolveIdentity = Effect.fn("StackApi.resolveIdentity")( - (options: Parameters[0]) => - provideServices(resolveStackIdentity(options)), + const findStack = Effect.fn("StackApi.find")((options: FindOptions) => + provideServices(find(options)), ); return StackApi.of({ create: createStack, open: openStack, discover: discoverStacks, - resolveIdentity, + find: findStack, }); }), ).pipe(Layer.provide(FetchHttpClient.layer)); diff --git a/apps/cli/src/command-internal/stack-auth-config.ts b/apps/cli/src/command-internal/stack-auth-config.ts index a42ab69216..d5f818e5d9 100644 --- a/apps/cli/src/command-internal/stack-auth-config.ts +++ b/apps/cli/src/command-internal/stack-auth-config.ts @@ -5,6 +5,27 @@ import type { ResolvedAuthExternalProvider } from "./local-config-values.ts"; import { passwordRequirementsToChar } from "./password-requirements.ts"; type AuthConfig = Extract["config"]; +type AuthSettings = NonNullable; + +/** Maps every key of `Source`, at any depth, that `Target` does not declare to `never`. */ +type DeclaredKeys = + Source extends ReadonlyArray + ? ReadonlyArray< + DeclaredKeys ? TargetItem : never> + > + : Source extends object + ? { + readonly [K in keyof Source]: K extends keyof Target + ? DeclaredKeys> + : never; + } + : Source; + +/** Passes a config section through unchanged; a field the stack does not declare fails type-checking. */ +const passThrough = + () => + (section: Source & DeclaredKeys>): Target => + section; interface ResolvedAuthOptions { readonly authExternalUrl?: string; @@ -26,7 +47,6 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( options: ResolvedAuthOptions, ): Effect.Effect => Effect.succeed({ - databaseUrl: "postgresql://placeholder", siteUrl: auth.site_url, ...(options.apiExternalUrl === undefined ? {} : { apiExternalUrl: options.apiExternalUrl }), ...(options.authExternalUrl === undefined @@ -47,7 +67,7 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( ...(auth.jwt_issuer === undefined ? {} : { jwtIssuer: auth.jwt_issuer }), ...(options.passkeyEnabled === undefined ? {} : { passkeyEnabled: options.passkeyEnabled }), ...(options.webauthn === undefined ? {} : { webauthn: options.webauthn }), - rateLimit: auth.rate_limit, + rateLimit: passThrough()(auth.rate_limit), email: { enable_signup: auth.email.enable_signup, double_confirm_changes: auth.email.double_confirm_changes, @@ -69,11 +89,15 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( ]), ), }, - sms: auth.sms, - ...(auth.captcha === undefined ? {} : { captcha: auth.captcha }), - hooks: auth.hook, - mfa: auth.mfa, - ...(auth.sessions === undefined ? {} : { sessions: auth.sessions }), + sms: passThrough()(auth.sms), + ...(auth.captcha === undefined + ? {} + : { captcha: passThrough()(auth.captcha) }), + hooks: passThrough()(auth.hook), + mfa: passThrough()(auth.mfa), + ...(auth.sessions === undefined + ? {} + : { sessions: passThrough()(auth.sessions) }), external: Object.fromEntries( Object.entries(options.externalProviders).map(([name, provider]) => [ name, @@ -88,8 +112,8 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( }, ]), ), - web3: auth.web3, - oauthServer: auth.oauth_server, + web3: passThrough()(auth.web3), + oauthServer: passThrough()(auth.oauth_server), }, ...(auth.email.smtp?.enabled !== true ? localSmtp.enabled @@ -110,5 +134,5 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( : { senderName: auth.email.smtp.sender_name }), }, }), - }), + } satisfies AuthConfig), ); diff --git a/apps/cli/src/command-internal/stack-bootstrap.ts b/apps/cli/src/command-internal/stack-bootstrap.ts index 78dc1b3ee4..106f845702 100644 --- a/apps/cli/src/command-internal/stack-bootstrap.ts +++ b/apps/cli/src/command-internal/stack-bootstrap.ts @@ -6,20 +6,40 @@ import { parseConnectionString } from "./db-config.parse.ts"; import { DbConnection, type DbSession } from "./db-connection.service.ts"; import type { DbTomlValues } from "./db-config.toml-read.ts"; import { migrateAndSeed } from "./migrate-and-seed.ts"; +import { StackCatalogSetup, type StackCatalogSetupInput } from "./stack-catalog-setup.ts"; -/** Failure while applying the CLI-owned database bootstrap steps. */ +/** + * Failure while applying the CLI-owned database bootstrap steps: the database address is + * `unavailable`, the connection failed (`connect`), or a bootstrap statement failed (`apply`). + */ export class StackBootstrapError extends Data.TaggedError("StackBootstrapError")<{ + readonly reason: "unavailable" | "connect" | "apply"; readonly message: string; readonly cause?: unknown; }> {} +const bootstrapError = + (reason: StackBootstrapError["reason"]) => + (error: unknown): StackBootstrapError => + new StackBootstrapError({ + reason, + message: + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : String(error), + cause: error, + }); + const databaseConn = Effect.fn("StackBootstrap.databaseConnection")(function* ( database: DatabaseInstance, ) { const credentials = yield* database.credentials({ from: "host" }); const conn = parseConnectionString(credentials.databaseUrl ?? ""); if (conn === undefined) - return yield* new StackBootstrapError({ message: "failed to parse stack database URL" }); + return yield* new StackBootstrapError({ + reason: "unavailable", + message: "failed to parse stack database URL", + }); return conn; }); @@ -31,12 +51,16 @@ const withDatabaseSession = ( Effect.scoped( Effect.gen(function* () { const dbConn = yield* DbConnection; - const conn = yield* databaseConn(database); - const session = yield* dbConn.connect(user === undefined ? conn : { ...conn, user }, { - isLocal: true, - dnsResolver: "native", - }); - return yield* body(session); + const conn = yield* databaseConn(database).pipe( + Effect.catchTag("StackError", (cause) => Effect.fail(bootstrapError("unavailable")(cause))), + ); + const session = yield* dbConn + .connect(user === undefined ? conn : { ...conn, user }, { + isLocal: true, + dnsResolver: "native", + }) + .pipe(Effect.mapError(bootstrapError("connect"))); + return yield* body(session).pipe(Effect.mapError(bootstrapError("apply"))); }), ); @@ -52,28 +76,23 @@ export const applyStackWebhooksOnly = ( const tmpDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-webhooks-" }); yield* applyDatabaseWebhooks(session, fs, path, tmpDir, webhooksEnabled); }), - ).pipe( - Effect.mapError( - (error) => - new StackBootstrapError({ - message: - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : String(error), - cause: error, - }), - ), ); -/** Applies migrations and seeds after the stack catalog has initialized the database. */ -export const applyStackMigrateAndSeed = ( - database: DatabaseInstance, - workdir: string, - toml: Pick< +/** Project migrations and seeds applied after the stack catalog. */ +interface StackMigrations { + readonly workdir: string; + readonly toml: Pick< DbTomlValues, "migrationsEnabled" | "seed" | "pgDelta" | "schemaPaths" | "webhooksEnabled" - >, - experimental: boolean, + >; + readonly experimental: boolean; + /** Applies migrations up to this version; omitted applies all of them. */ + readonly version?: string; +} + +const applyStackMigrateAndSeed = ( + database: DatabaseInstance, + migrations: StackMigrations, ): Effect.Effect< void, StackBootstrapError, @@ -85,25 +104,40 @@ export const applyStackMigrateAndSeed = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* migrateAndSeed(session, fs, path, workdir, "", { + const { toml } = migrations; + yield* migrateAndSeed(session, fs, path, migrations.workdir, migrations.version ?? "", { migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, - experimental, + experimental: migrations.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, localDatabaseWebhooksEnabled: toml.webhooksEnabled, }); }), "postgres", - ).pipe( - Effect.mapError( - (error) => - new StackBootstrapError({ - message: - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : String(error), - cause: error, - }), - ), ); + +/** The catalog overlay declared by a project's `config.toml`. */ +export const projectCatalogOverlay = ( + toml: Pick, + workdir: string, +): StackCatalogSetupInput["overlay"] => ({ + webhooks: "config", + webhooksEnabled: toml.webhooksEnabled, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + workdir, +}); + +/** + * Initialises a started stack database: the catalog with its service schemas and overlay, then + * the project migrations and seeds when requested. + */ +export const initializeStackDatabase = Effect.fn("StackBootstrap.initializeDatabase")(function* ( + input: StackCatalogSetupInput & { readonly migrations?: StackMigrations }, +) { + const catalog = yield* StackCatalogSetup; + yield* catalog.apply({ target: input.target, overlay: input.overlay }); + if (input.migrations !== undefined) + yield* applyStackMigrateAndSeed(input.target.database, input.migrations); +}); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 08e48e349d..10607997c0 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -64,13 +64,12 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { }, { service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + config: { jwtSecret }, endpoints: { http: { port: "auto" } }, }, { service: "storage", config: { - databaseUrl: "postgresql://placeholder", jwtSecret, filePath: `${root}/unused-storage`, }, @@ -78,7 +77,7 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { }, { service: "realtime", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + config: { jwtSecret }, endpoints: { http: { port: "auto" }, rpc: { port: "auto" } }, }, ]); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.ts b/apps/cli/src/command-internal/stack-catalog-setup.ts index 585c27199f..714e683ac5 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.ts @@ -22,6 +22,15 @@ import type { MigrationVaultError, VaultSecret } from "./vault.ts"; const DATABASE_SERVICES = ["auth", "storage", "realtime"] as const; type DatabaseService = (typeof DATABASE_SERVICES)[number]; +const isDatabaseService = (service: string): service is DatabaseService => + DATABASE_SERVICES.some((candidate) => candidate === service); + +/** Selects the services whose database schemas the catalog provisions. */ +export const catalogDatabaseServices = ( + services: ReadonlyArray<{ readonly service: string }>, +): ReadonlyArray => + services.flatMap(({ service }) => (isDatabaseService(service) ? [service] : [])); + interface ServiceCredentials { readonly databaseUrl: string; readonly authDatabaseUrl: string; diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 300fdb4e75..e33ad30cc3 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -77,7 +77,7 @@ interface StackStartConfig { readonly projectEnvValues: Readonly>; readonly document?: Record; readonly remoteJwks: Effect.Effect; - readonly identity: Effect.Effect< + readonly keys: Effect.Effect< { readonly publishableKey?: string; readonly secretKey?: string; @@ -895,7 +895,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( message: cause instanceof Error ? cause.message : String(cause), }), }); - const localIdentity = Effect.try({ + const localKeys = Effect.try({ try: () => { const configured = (value: string | undefined) => value === undefined || value === "" @@ -954,8 +954,8 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ), ); }); - const identity = Effect.gen(function* () { - const configuredKeys = yield* localIdentity; + const keys = Effect.gen(function* () { + const configuredKeys = yield* localKeys; const refreshedRemoteJwks = yield* remoteJwks; return { ...configuredKeys, @@ -1132,6 +1132,11 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const poolMode = validatedConfig.db.pooler.pool_mode === "session" ? ("session" as const) : "transaction"; const storagePath = `${projectRoot}/supabase/.temp/stack-uploads`; + const pgmetaCreation: ServiceCreationType = { + service: "pgmeta", + config: {}, + endpoints: { http: endpoint(undefined) }, + }; const createCreations = ( stackId: string, ): Effect.Effect, StackConfigError> => @@ -1154,7 +1159,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "analytics" as const, config: { - databaseUrl: "postgresql://placeholder", backend: "postgres" as const, apiKey: "api-key", }, @@ -1167,7 +1171,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "pooler" as const, config: { - databaseUrl: "postgresql://placeholder", ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), poolMode, tenant: "pooler-dev", @@ -1178,21 +1181,12 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( } satisfies ServiceCreationType, ] : []), - ...(validatedConfig.studio.enabled - ? [ - { - service: "pgmeta" as const, - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: endpoint(undefined) }, - } satisfies ServiceCreationType, - ] - : []), + ...(validatedConfig.studio.enabled ? [pgmetaCreation] : []), ...(validatedConfig.api.enabled ? [ { service: "rest" as const, config: { - databaseUrl: "postgresql://placeholder", schemas: validatedConfig.api.schemas.join(","), extraSearchPath: validatedConfig.api.extra_search_path.join(","), maxRows: validatedConfig.api.max_rows, @@ -1222,7 +1216,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "realtime" as const, config: { - databaseUrl: "postgresql://placeholder", ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), ipVersion: validatedConfig.realtime.ip_version === "IPv6" @@ -1242,7 +1235,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "storage" as const, config: { - databaseUrl: "postgresql://placeholder", ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), filePath: `${storagePath}/${stackId}`, fileSizeLimit: storageFileSizeLimit, @@ -1259,7 +1251,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ? [ { service: "vector" as const, - config: { analyticsUrl: "http://analytics", apiKey: "api-key" }, + config: { apiKey: "api-key" }, endpoints: { http: endpoint( envPortOrConfigured( @@ -1350,7 +1342,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( source: validatedConfig, projectEnvValues: context.projectEnvValues, remoteJwks, - identity, + keys, ...(context.loaded?.document === undefined ? {} : { document: context.loaded.document }), }; }), diff --git a/apps/cli/src/command-internal/stack-functions-env.ts b/apps/cli/src/command-internal/stack-functions-env.ts index 427306d46e..27f55fe62f 100644 --- a/apps/cli/src/command-internal/stack-functions-env.ts +++ b/apps/cli/src/command-internal/stack-functions-env.ts @@ -1,4 +1,5 @@ import { Data, Effect, FileSystem, Predicate } from "effect"; +import type { ServiceCreationInput } from "@supabase/stack/effect"; import { parseDotEnv } from "./dotenv.ts"; import { actionability, @@ -59,3 +60,14 @@ export const readStackFunctionsEnv = Effect.fn("StackFunctionsEnv.read")(functio }); return env; }); + +/** Adds the project Functions dotenv values to a Functions creation; configured values win. */ +export const withProjectFunctionsEnv = (creation: ServiceCreationInput) => + creation.service === "functions" + ? readStackFunctionsEnv(`${creation.config.functionsRoot}/.env`, true).pipe( + Effect.map((env): ServiceCreationInput => ({ + ...creation, + config: { ...creation.config, env: { ...env, ...creation.config.env } }, + })), + ) + : Effect.succeed(creation); diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 4980f6fe34..a3ab5899da 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -1,6 +1,5 @@ import { Data, Effect, FileSystem, Option, Path, Redacted } from "effect"; import type { DatabaseInstance, ServiceCreationInput, Stack } from "@supabase/stack/effect"; -import { postgresVersion } from "@supabase/stack/internal/postgres-artifact"; import { actionability, type CliErrorActionabilityDeclaration, @@ -13,9 +12,13 @@ import { currentStackBackend } from "./stack-backend.ts"; import { StackApi } from "./stack-api.ts"; import { loadStackConfig } from "./stack-config.ts"; import { readDbToml } from "./db-config.toml-read.ts"; -import { StackCatalogSetup } from "./stack-catalog-setup.ts"; +import { catalogDatabaseServices } from "./stack-catalog-setup.ts"; import { resolveExperimentalWithProjectEnv } from "./global-flags.ts"; -import { applyStackMigrateAndSeed, applyStackWebhooksOnly } from "./stack-bootstrap.ts"; +import { + applyStackWebhooksOnly, + initializeStackDatabase, + projectCatalogOverlay, +} from "./stack-bootstrap.ts"; import { automaticRuntimeNotice, selectStackRuntime } from "./stack-runtime.ts"; import { Output } from "../shared/output/output.service.ts"; import type { PgConnInput } from "./db-connection.service.ts"; @@ -36,20 +39,10 @@ type StackInstances = Effect.Success; const databaseFor = (instances: StackInstances): DatabaseInstance | undefined => instances.find((instance): instance is DatabaseInstance => instance.service === "database"); -const stackForProject = Effect.fn("StackLocalDatabase.findProject")(function* ( - api: StackApi["Service"], - settings: Settings, - path: Path.Path, -) { - const identity = yield* api.resolveIdentity({ projectRoot: settings.workdir }); - const discovered = yield* api.discover({ stateRoot: stateRoot(settings, path) }); - return discovered.find( - ({ definition }) => - definition.identity.projectRoot === identity.projectRoot && - definition.identity.branchContext === identity.branchContext && - definition.identity.stackName === identity.stackName, - ); -}); +const stackForProject = (api: StackApi["Service"], settings: Settings, path: Path.Path) => + api + .find({ stateRoot: stateRoot(settings, path), projectRoot: settings.workdir }) + .pipe(Effect.map(Option.getOrUndefined)); const openProjectStack = Effect.fn("StackLocalDatabase.openProject")(function* () { const api = yield* StackApi; @@ -103,12 +96,13 @@ export const stackOpenReadyProject = stackOpenProjectBy((cause) => notRunning(ca ), ); +/** The saved project stack's runtime as a hint; an absent or unreadable stack yields none. */ export const stackProjectRuntime = Effect.gen(function* () { const api = yield* StackApi; const settings = yield* CommandSettings; const path = yield* Path.Path; return (yield* stackForProject(api, settings, path))?.definition.runtime; -}); +}).pipe(Effect.orElseSucceed(() => undefined)); export class StackRuntimeUnavailableError extends Data.TaggedError("StackRuntimeUnavailableError")<{ readonly message: string; @@ -132,13 +126,6 @@ export const stackRequireProjectRuntime: Effect.Effect< Effect.flatMap((runtime) => runtime === undefined ? Effect.fail(RUNTIME_UNAVAILABLE) : Effect.succeed(runtime), ), - Effect.mapError( - (cause) => - new StackRuntimeUnavailableError({ - message: cause.message, - suggestion: RUNTIME_UNAVAILABLE.suggestion, - }), - ), ); /** Reads the configured database version from the saved database definition. */ @@ -228,8 +215,8 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( const toml = yield* readDbToml(fs, path, settings.workdir).pipe(Effect.mapError(startFailed)); const experimental = yield* resolveExperimentalWithProjectEnv({ ...toml.projectEnv }); const existing = yield* stackForProject(api, settings, path).pipe(Effect.mapError(startFailed)); - const identity = - existing === undefined ? yield* config.identity.pipe(Effect.mapError(startFailed)) : undefined; + const keys = + existing === undefined ? yield* config.keys.pipe(Effect.mapError(startFailed)) : undefined; const createStack = Effect.gen(function* () { const runtime = yield* selectStackRuntime(undefined).pipe( Effect.mapError( @@ -272,11 +259,16 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( return yield* startFailed({ message: "database is disabled in the local configuration" }); const currentDatabase = yield* databaseFromStack(stack).pipe(Effect.mapError(startFailed)); if (currentDatabase !== undefined) { - const status = yield* currentDatabase.status.pipe(Effect.mapError(startFailed)); + const planned = yield* stack.composition + .plan([databaseCreation]) + .pipe(Effect.mapError(startFailed)); if ( - status.config.service === "database" && - postgresVersion(status.config.config.version) !== - postgresVersion(databaseCreation.config.version) + planned.some( + (entry) => + entry.id === currentDatabase.id && + entry.change === "incompatible" && + entry.paths.includes("config.version"), + ) ) return yield* startFailed({ message: "The requested database version does not match the saved stack binding", @@ -311,41 +303,23 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( message: "A standalone database exists outside the saved stack composition. Destroy the standalone database before starting this stack.", }); - const effectiveIdentity = identity ?? (yield* config.identity.pipe(Effect.mapError(startFailed))); + const effectiveKeys = keys ?? (yield* config.keys.pipe(Effect.mapError(startFailed))); const [database] = yield* stack.composition - .supabase([databaseCreation], { identity: effectiveIdentity }) + .supabase([databaseCreation], { keys: effectiveKeys }) .pipe(Effect.mapError(startFailed)); if (database === undefined || database.service !== "database") return yield* startFailed({ message: "stack did not create a database instance" }); return yield* Effect.gen(function* () { yield* database.start.pipe(Effect.mapError(startFailed)); yield* database.ready.pipe(Effect.mapError(startFailed)); - const catalog = yield* StackCatalogSetup; - const databaseServices = creations.flatMap((creation) => - creation.service === "auth" || - creation.service === "storage" || - creation.service === "realtime" - ? [creation.service] - : [], - ); const credentials = yield* stack.credentials.get.pipe(Effect.mapError(startFailed)); if (credentials === undefined) return yield* startFailed({ message: "stack credentials were not saved" }); - yield* catalog - .apply({ - target: { stack, database, databaseServices }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir: settings.workdir, - }, - }) - .pipe(Effect.mapError(startFailed)); - yield* applyStackMigrateAndSeed(database, settings.workdir, toml, experimental).pipe( - Effect.mapError(startFailed), - ); + yield* initializeStackDatabase({ + target: { stack, database, databaseServices: catalogDatabaseServices(creations) }, + overlay: projectCatalogOverlay(toml, settings.workdir), + migrations: { workdir: settings.workdir, toml, experimental }, + }).pipe(Effect.mapError(startFailed)); return "started" as const; }).pipe( Effect.onError(() => diff --git a/apps/cli/src/command-internal/stack-shadow-cache.ts b/apps/cli/src/command-internal/stack-shadow-cache.ts index 7b267f4d5f..0249891d0d 100644 --- a/apps/cli/src/command-internal/stack-shadow-cache.ts +++ b/apps/cli/src/command-internal/stack-shadow-cache.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { resolveArtifact, postgresVersion } from "@supabase/stack/internal/postgres-artifact"; +import { resolveArtifact, postgresVersion } from "@supabase/stack/internal/artifacts"; import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; import { SHADOW_CACHE_ENV, diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index 377dba0b01..86c2ee6276 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -4,7 +4,7 @@ import { CommandSettings } from "../config/command-settings.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { skippedRuntimeCleanupWarning, StackApi } from "./stack-api.ts"; -import { StackCatalogSetup } from "./stack-catalog-setup.ts"; +import { initializeStackDatabase } from "./stack-bootstrap.ts"; import { stackProjectRuntime } from "./stack-local-database.ts"; import { selectStackRuntime } from "./stack-runtime.ts"; import { parseConnectionString } from "./db-config.parse.ts"; @@ -157,9 +157,8 @@ const initialize = Effect.fn("StackShadow.initialize")(function* ( } else { yield* startReady(database); } - const catalog = yield* StackCatalogSetup; if (!restored) - yield* catalog.apply({ + yield* initializeStackDatabase({ target: { stack, database, diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index 4f69a6dadd..158e40ecc9 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -42,7 +42,7 @@ const stackApiStub = Layer.succeed(StackApi, { create: () => Effect.die("stack API unused"), open: () => Effect.die("stack API unused"), discover: () => Effect.die("stack API unused"), - resolveIdentity: () => Effect.die("stack API unused"), + find: () => Effect.die("stack API unused"), }); function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 28d0420730..b271d4e65e 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -129,6 +129,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { }, credentials: { get: Effect.die("unused") }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed({ members: [{ id, activation: "eager" as const }], dependencies: [], @@ -146,9 +147,10 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { return Layer.succeed(StackApi, { create: () => Effect.succeed(stack), open: () => Effect.succeed(stack), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id, identity: { projectRoot: "/work/project", branchContext: "main", stackName: "default" }, @@ -159,10 +161,8 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { ports: [], }, host: undefined, - }, - ]), - resolveIdentity: () => - Effect.succeed({ projectRoot: "/work/project", branchContext: "main", stackName: "default" }), + }), + ), }); }; @@ -170,7 +170,7 @@ const unusedStackApi = Layer.succeed(StackApi, { create: () => Effect.die("unused"), open: () => Effect.die("unused"), discover: () => Effect.die("unused"), - resolveIdentity: () => Effect.die("unused"), + find: () => Effect.die("unused"), }); function mockResolver(opts: { diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index cec5f895d1..8f889211d1 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -551,6 +551,7 @@ const stackService = ( readonly ready?: Effect.Effect; readonly stop?: Effect.Effect; readonly resetData?: Effect.Effect; + readonly credentials?: Effect.Effect>, StackError>; } = {}, ): StackService => { const base = { @@ -572,6 +573,7 @@ const stackService = ( ...base, service: "database", credentials: () => + overrides.credentials ?? Effect.succeed({ databaseUrl: "postgresql://postgres:postgres@127.0.0.1:5432/postgres" }), saveSnapshot: () => Effect.die("unused"), restoreSnapshot: () => Effect.die("unused"), @@ -638,6 +640,8 @@ function mockResetStackApi(opts: { readonly apiEndpoint?: { readonly url: string; readonly port: number }; /** Models the `db start` overlay, whose composition holds only the database. */ readonly postgresOnly?: boolean; + /** Makes the database address unavailable once the reset has started it again. */ + readonly unavailableAfterReset?: boolean; /** Fails every database status read, as an unreachable or mismatched owner does. */ readonly statusFailure?: StackError; }) { @@ -730,6 +734,13 @@ function mockResetStackApi(opts: { startCalls++; }), ready: Effect.void, + ...(opts.unavailableAfterReset === true + ? { + credentials: Effect.fail( + new StackError({ operation: "credentials", message: "owner unavailable" }), + ), + } + : {}), }); const composed: Array = [ db, @@ -763,6 +774,7 @@ function mockResetStackApi(opts: { }, credentials: { get: Effect.succeed(RESET_STACK_CREDENTIALS) }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed({ members: members.map(({ id }, index) => ({ id, @@ -800,11 +812,10 @@ function mockResetStackApi(opts: { return { layer: Layer.succeed(StackApi, { create: () => Effect.die("unused"), - resolveIdentity: () => - Effect.succeed({ projectRoot: opts.workdir, branchContext: "main", stackName: "default" }), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: RESET_STACK_ID, identity: { projectRoot: opts.workdir, branchContext: "main", stackName: "default" }, @@ -818,8 +829,8 @@ function mockResetStackApi(opts: { ports: [], }, host: undefined, - }, - ]), + }), + ), open: () => Effect.succeed(stack), }), get resetCalls() { @@ -890,6 +901,7 @@ function setup( stackStorageError?: string; stackApiEndpoint?: { readonly url: string; readonly port: number }; stackPostgresOnly?: boolean; + stackUnavailableAfterReset?: boolean; stackStatusFailure?: StackError; httpClient?: Layer.Layer; }, @@ -933,6 +945,7 @@ function setup( storageError: opts.stackStorageError, apiEndpoint: opts.stackApiEndpoint, postgresOnly: opts.stackPostgresOnly, + unavailableAfterReset: opts.stackUnavailableAfterReset, statusFailure: opts.stackStatusFailure, }); const catalog = @@ -1411,6 +1424,24 @@ describe("db reset", () => { }); }); + it.live("reports a database that becomes unavailable after the reset as not running", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackUnavailableAfterReset: true, + }); + return Effect.gen(function* () { + const error = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "ResetLocalDbNotRunningError", + message: "owner unavailable", + }); + expect(stackApi.resetCalls).toBe(1); + }); + }); + const BUCKET_TOML = ['project_id = "test"', "[storage.buckets.dogfood]", "public = true"].join( "\n", ); diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index fa8660e2a3..fe5a8d1def 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -136,7 +136,6 @@ const composeStack = Effect.fn("DbResetStackE2e.composeStack")(function* ( { service: "rest", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: JWT_SECRET, }, endpoints: { http: { port: "auto" } }, @@ -144,7 +143,6 @@ const composeStack = Effect.fn("DbResetStackE2e.composeStack")(function* ( { service: "auth", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: JWT_SECRET, }, endpoints: { http: { port: "auto" } }, diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 189ff86b68..7344c9711b 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -154,6 +154,7 @@ function generateStackApi(workdir: string) { }, credentials: { get: unusedStack }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed(composition), supabase: unusedStackFn, configure: unusedStackFn, @@ -169,10 +170,10 @@ function generateStackApi(workdir: string) { return Layer.succeed(StackApi, { create: unusedStackFn, open: () => Effect.succeed(stack), - resolveIdentity: () => Effect.succeed(identity), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: stack.id, identity, @@ -183,8 +184,8 @@ function generateStackApi(workdir: string) { ports: [], }, host: undefined, - }, - ]), + }), + ), }); } diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 10fda76a0e..85ebc7eaaa 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -156,6 +156,7 @@ function syncStackApi(workdir: string, port: number) { }, credentials: { get: unusedSync }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed(composition), supabase: unusedSyncFn, configure: unusedSyncFn, @@ -171,10 +172,10 @@ function syncStackApi(workdir: string, port: number) { return Layer.succeed(StackApi, { create: unusedSyncFn, open: () => Effect.succeed(stack), - resolveIdentity: () => Effect.succeed(identity), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: stack.id, identity, @@ -185,8 +186,8 @@ function syncStackApi(workdir: string, port: number) { ports: [], }, host: undefined, - }, - ]), + }), + ), }); } diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 5bfdb14e1a..2ede82a5ec 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -47,6 +47,7 @@ import type { DbStartFlags } from "./start.command.ts"; import { stackLocalDatabaseConn } from "../../../command-internal/stack-local-database.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { StackApi } from "../../../command-internal/stack-api.ts"; +import { postgresVersion } from "@supabase/stack/internal/artifacts"; import { StackCatalogSetup, StackCatalogSetupError, @@ -361,7 +362,7 @@ function setup(opts: SetupOpts = {}) { create: () => Effect.die("unused"), open: () => Effect.die("unused"), discover: () => Effect.die("unused"), - resolveIdentity: () => Effect.die("unused"), + find: () => Effect.die("unused"), }); const layer = Layer.mergeAll( @@ -1689,6 +1690,29 @@ describe("db start stack backend", () => { }), }, composition: { + plan: (creations: ReadonlyArray) => + Effect.sync(() => + creations.flatMap((creation) => + creation.service === "database" && registered.includes(database) + ? [ + postgresVersion(creation.config.version) === postgresVersion(version) + ? { + id: database.id, + service: "database" as const, + member: members.includes(database), + change: "unchanged" as const, + } + : { + id: database.id, + service: "database" as const, + member: members.includes(database), + change: "incompatible" as const, + paths: ["config.version"], + }, + ] + : [], + ), + ), describe: Effect.sync(() => ({ members: members.map(({ id }) => ({ id, activation: "eager" as const })), dependencies: [], @@ -1715,27 +1739,24 @@ describe("db start stack backend", () => { Layer.succeed(StackApi, { create: () => Effect.succeed(fixture.stack), open: () => Effect.succeed(fixture.stack), - discover: () => + discover: () => Effect.die("unused"), + find: () => Effect.succeed( existing - ? [ - { - definition: { - id: stackId, - identity: { projectRoot: root, branchContext: "main", stackName: "default" }, - runtime: "native", - instances: [], - lifetime: "detached", - composition: { members: [], dependencies: [] }, - ports: [], - }, - host: undefined, + ? Option.some({ + definition: { + id: stackId, + identity: { projectRoot: root, branchContext: "main", stackName: "default" }, + runtime: "native" as const, + instances: [], + lifetime: "detached" as const, + composition: { members: [], dependencies: [] }, + ports: [], }, - ] - : [], + host: undefined, + }) + : Option.none(), ), - resolveIdentity: () => - Effect.succeed({ projectRoot: root, branchContext: "main", stackName: "default" }), }); it.live("creates and starts only the primary database", () => { diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 70d10bc54c..82cb11b65a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -104,7 +104,6 @@ describe("stack destroy", () => { f.stack.services.create({ service: "storage", config: { - databaseUrl: "postgresql://unused", jwtSecret: "test-secret", filePath: uploads, }, diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 262bb07549..f9127c4023 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -63,10 +63,7 @@ export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags: stateRoot: path.join(settings.supabaseHome, "stacks"), cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), }; - const discovery = yield* api.discover(locations).pipe(Effect.mapError(logsError)); - if ( - !discovery.some(({ definition, host }) => definition.id === target.id && host !== undefined) - ) + if (!target.hostRunning) return yield* new StackCommandLogsError({ reason: "lifecycle", message: "No owner is reachable; live logs are unavailable.", diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index b0b97acf6c..32d6862aa3 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -144,6 +144,7 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => { }, credentials: { get: Effect.die("unused") }, composition: { + plan: () => Effect.succeed([]), supabase: () => Effect.die("prepare must not change the composition"), configure: () => Effect.void, describe: Effect.succeed({ @@ -191,7 +192,7 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => { return stack; }), discover: () => Effect.succeed([]), - resolveIdentity: () => Effect.die("unused"), + find: () => Effect.die("unused"), }), ); return { diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 62fe1e1e9b..990121d197 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -54,11 +54,11 @@ enabled = true for (const service of services) yield* Schema.decodeEffect(ServiceCreationInput)(service); const recipes = byService(services); - const identity = yield* config.identity; - expect(identity.anonKey).toBeUndefined(); - expect(identity.serviceRoleKey).toBeUndefined(); - expect(identity.gotrueJwtKeys).toBeUndefined(); - expect(identity.publicSigningKeys).toBeUndefined(); + const keys = yield* config.keys; + expect(keys.anonKey).toBeUndefined(); + expect(keys.serviceRoleKey).toBeUndefined(); + expect(keys.gotrueJwtKeys).toBeUndefined(); + expect(keys.publicSigningKeys).toBeUndefined(); const database = recipes.get("database"); expect( database?.service === "database" ? database.config.rootKey : undefined, @@ -184,9 +184,9 @@ signing_keys_path = "./keys.json" `); yield* fs.writeFileString(path.join(enabled, "supabase", "keys.json"), "[]"); const enabledConfig = yield* load(enabled); - const identity = yield* enabledConfig.identity; - expect(identity.gotrueJwtKeys).toBe("[]"); - expect(identity.publicSigningKeys).toBe("[]"); + const keys = yield* enabledConfig.keys; + expect(keys.gotrueJwtKeys).toBe("[]"); + expect(keys.publicSigningKeys).toBe("[]"); const disabled = yield* project(`project_id = "stack-config-disabled-signing-keys" [auth] @@ -194,19 +194,19 @@ enabled = false signing_keys_path = "./missing-keys.json" `); const disabledConfig = yield* load(disabled); - const disabledIdentity = yield* disabledConfig.identity; + const disabledKeys = yield* disabledConfig.keys; const jwks = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Array(publicJwkSchema)))( - disabledIdentity.publicSigningKeys ?? "[]", + disabledKeys.publicSigningKeys ?? "[]", ); expect(jwks).toHaveLength(1); expect(jwks[0]?.kid).toBe(DEFAULT_SIGNING_KEY.kid); - expect(disabledIdentity.publicSigningKeys).not.toContain('"d"'); + expect(disabledKeys.publicSigningKeys).not.toContain('"d"'); const publicJwk = jwks[0]; if (publicJwk === undefined) return yield* Effect.die("The default public JWK is missing."); const publicKey = yield* Effect.promise(() => importJWK(publicJwk, "ES256")); for (const [token, role] of [ - [disabledIdentity.anonKey, "anon"], - [disabledIdentity.serviceRoleKey, "service_role"], + [disabledKeys.anonKey, "anon"], + [disabledKeys.serviceRoleKey, "service_role"], ] as const) { expect(token).toBeDefined(); const verified = yield* Effect.promise(() => @@ -219,10 +219,10 @@ signing_keys_path = "./missing-keys.json" `project_id = "stack-config-env-disabled-signing-keys"\n[auth]\nenabled = false\n`, { supabaseEnv: "SUPABASE_AUTH_SIGNING_KEYS_PATH=./missing-keys.json\n" }, ); - const envIdentity = yield* (yield* load(envDisabled)).identity; - expect(envIdentity.publicSigningKeys).toBe(disabledIdentity.publicSigningKeys); - expect(envIdentity.anonKey).toBeDefined(); - expect(envIdentity.serviceRoleKey).toBeDefined(); + const envKeys = yield* (yield* load(envDisabled)).keys; + expect(envKeys.publicSigningKeys).toBe(disabledKeys.publicSigningKeys); + expect(envKeys.anonKey).toBeDefined(); + expect(envKeys.serviceRoleKey).toBeDefined(); }).pipe(Effect.provide(BunServices.layer)), ); @@ -254,10 +254,10 @@ signing_keys_path = "./missing-keys.json" }, ); const config = yield* load(root); - const identity = yield* config.identity; + const keys = yield* config.keys; const remoteJwks = yield* Schema.decodeEffect( Schema.fromJsonString(Schema.Array(remoteJwkSchema)), - )(identity.remoteJwks ?? "[]"); + )(keys.remoteJwks ?? "[]"); expect(remoteJwks).toEqual([remoteKey]); expect(paths).toEqual(["/.well-known/openid-configuration", "/jwks"]); @@ -268,8 +268,8 @@ enabled = false enabled = true issuer_url = "" `); - const emptyIssuerIdentity = yield* (yield* load(emptyIssuer)).identity; - expect(emptyIssuerIdentity.remoteJwks).toBeUndefined(); + const emptyIssuerKeys = yield* (yield* load(emptyIssuer)).keys; + expect(emptyIssuerKeys.remoteJwks).toBeUndefined(); expect(paths).toEqual(["/.well-known/openid-configuration", "/jwks"]); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts index 984b57cc34..79ad9f79c4 100644 --- a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts @@ -61,30 +61,40 @@ vector_port = 59001 realtime?.service === "realtime" ? realtime.config.secretKeyBase : undefined, ).toBeUndefined(); expect(services.find((s) => s.service === "vector")?.endpoints?.http?.port).toBe(59001); + // A composition binds each database URL before launch. + const bound = (creation: C) => ({ + ...creation, + config: { + ...creation.config, + databaseUrl: "postgresql://supabase_admin@127.0.0.1/postgres", + }, + }); for (const container of [false, true]) { for (const service of services) { switch (service.service) { case "rest": - expect(yield* restSpec().env(service, new Map(), container)).toMatchObject({ + expect(yield* restSpec().env(bound(service), new Map(), container)).toMatchObject({ PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions,custom", }); break; case "pooler": - expect(yield* poolerSpec().env(service, new Map(), container)).toMatchObject({ + expect(yield* poolerSpec().env(bound(service), new Map(), container)).toMatchObject({ TENANT_ID: "pooler-dev", DEFAULT_POOL_SIZE: "7", MAX_CLIENT_CONN: "42", }); break; case "realtime": - expect(yield* realtimeSpec().env(service, new Map(), container)).toMatchObject({ - MAX_HEADER_LENGTH: "8192", - ERL_AFLAGS: "-proto_dist inet6_tcp", - DB_IP_VERSION: "ipv4", - }); + expect(yield* realtimeSpec().env(bound(service), new Map(), container)).toMatchObject( + { + MAX_HEADER_LENGTH: "8192", + ERL_AFLAGS: "-proto_dist inet6_tcp", + DB_IP_VERSION: "ipv4", + }, + ); break; case "storage": - expect(yield* storageSpec().env(service, new Map(), container)).toMatchObject({ + expect(yield* storageSpec().env(bound(service), new Map(), container)).toMatchObject({ S3_PROTOCOL_ENABLED: "false", VECTOR_ENABLED: "false", VECTOR_MAX_BUCKETS: "3", @@ -304,6 +314,7 @@ allow_dynamic_registration = true ...auth, config: { ...auth.config, + databaseUrl: "postgresql://supabase_auth_admin@127.0.0.1/postgres", smtpUrl: "smtp://127.0.0.1:1025", }, }, diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index d23336c08c..ed4e9742cb 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -1,7 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { resolveStackIdentity } from "@supabase/stack/internal/identity"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { StackApi, StackTargetError, @@ -18,67 +17,59 @@ type TargetInput = { readonly runtime: "auto" | "docker" | "podman" | "native"; }; -type DiscoveredStack = Effect.Success< - ReturnType ->[number]["definition"]; - -const stack = ( - id: string, - identity: DiscoveredStack["identity"], - runtime: DiscoveredStack["runtime"] = "native", -): DiscoveredStack => ({ - id, - identity, - runtime, - instances: [], - lifetime: "detached", - composition: { members: [], dependencies: [] }, - ports: [], +const workspace = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-" })); + const home = path.join(root, ".supabase"); + const register = (options: { + readonly projectRoot: string; + readonly name?: string; + readonly runtime?: "native" | "docker"; + }) => + Effect.gen(function* () { + const api = yield* StackApi; + const stack = yield* api.create({ + projectRoot: options.projectRoot, + ...(options.name === undefined ? {} : { name: options.name }), + stateRoot: path.join(home, "stacks"), + cacheRoot: path.join(home, "cache", "stack"), + runtime: options.runtime ?? "native", + }); + return stack.id; + }).pipe(Effect.scoped, Effect.provide(stackApiLayer)); + const resolve = (input: TargetInput) => + Effect.gen(function* () { + const resolver = yield* StackTargetResolver; + return yield* resolver.resolve(input); + }).pipe( + Effect.provide( + stackTargetResolverLayer.pipe( + Layer.provide(mockCommandSettings({ workdir: root, supabaseHome: home })), + Layer.provide(stackApiLayer), + ), + ), + ); + return { root, home, register, resolve }; }); -const resolverLayer = ( - discovered: ReadonlyArray, - workdir: string, - supabaseHome: string, -) => { - const api = Layer.succeed(StackApi, { - create: () => Effect.die("unused"), - open: () => Effect.die("unused"), - discover: () => - Effect.succeed(discovered.map((definition) => ({ definition, host: undefined }))), - resolveIdentity: (options) => - resolveStackIdentity(options).pipe(Effect.provide(BunServices.layer)), - }); - return stackTargetResolverLayer.pipe( - Layer.provide(mockCommandSettings({ workdir, supabaseHome })), - Layer.provide(api), - Layer.provide(BunServices.layer), - ); -}; - -const resolveTarget = (layer: Layer.Layer, input: TargetInput) => - Effect.gen(function* () { - const resolver = yield* StackTargetResolver; - return yield* resolver.resolve(input); - }).pipe(Effect.provide(layer)); - describe("stack target resolver", () => { it.live("resolves an explicit id without reading the current project identity", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-id-" }); + const { root, register, resolve } = yield* workspace; const storedRoot = path.join(root, "stored-project"); - const id = "a".repeat(64); - const target = yield* resolveTarget( - resolverLayer( - [stack(id, { projectRoot: storedRoot, branchContext: "main", stackName: "default" })], - root, - path.join(root, ".supabase"), - ), - { projectRoot: path.join(root, "missing-current-project"), id, runtime: "auto" }, - ); - expect(target).toEqual({ + yield* fs.makeDirectory(storedRoot); + const id = yield* register({ projectRoot: storedRoot }); + + const target = yield* resolve({ + projectRoot: path.join(root, "missing-current-project"), + id, + runtime: "auto", + }); + + expect(target).toMatchObject({ projectRoot: storedRoot, id, runtime: "native", @@ -89,69 +80,84 @@ describe("stack target resolver", () => { it.live("rejects an explicit id when its saved runtime differs", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-runtime-" }); - const id = "b".repeat(64); - const exit = yield* resolveTarget( - resolverLayer( - [stack(id, { projectRoot: root, branchContext: "main", stackName: "default" }, "docker")], - root, - path.join(root, ".supabase"), - ), - { projectRoot: root, id, runtime: "native" }, - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Exit.findErrorOption(exit); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(StackTargetError); - if (failure.value instanceof StackTargetError) { - expect(failure.value.reason).toBe("flags"); - expect(failure.value.message).toContain("Requested runtime native"); - expect(failure.value.message).toContain("existing stack runtime docker"); - expect(failure.value.suggestion).toContain("--runtime auto"); - expect(failure.value.suggestion).toContain("different --stack name"); - } - } - } + const { root, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root, runtime: "docker" }); + + const failure = yield* resolve({ projectRoot: root, id, runtime: "native" }).pipe( + Effect.flip, + ); + + expect(failure).toBeInstanceOf(StackTargetError); + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain("Requested runtime native"); + expect(failure.message).toContain("existing stack runtime docker"); + expect(failure.suggestion).toContain("--runtime auto"); + expect(failure.suggestion).toContain("different --stack name"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("selects the saved stack whose identity the project and stack name derive", () => + Effect.gen(function* () { + const { root, register, resolve } = yield* workspace; + yield* register({ projectRoot: root }); + const id = yield* register({ projectRoot: root, name: "feature" }); + + const target = yield* resolve({ projectRoot: root, name: "feature", runtime: "auto" }); + + expect(target).toMatchObject({ projectRoot: root, id, name: "feature" }); + expect(target.definition?.identity.stackName).toBe("feature"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("reports no id when the project has no saved stack", () => + Effect.gen(function* () { + const { root, resolve } = yield* workspace; + + const target = yield* resolve({ projectRoot: root, runtime: "docker" }); + + expect(target).toEqual({ projectRoot: root, runtime: "docker", hostRunning: false }); }).pipe(Effect.provide(BunServices.layer)), ); it.live("carries an explicit Podman runtime onto a new stack target", () => + Effect.gen(function* () { + const { root, resolve } = yield* workspace; + + const target = yield* resolve({ projectRoot: root, runtime: "podman" }); + + expect(target).toMatchObject({ runtime: "podman", hostRunning: false }); + expect(target.id).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("returns the canonical project root for a new stack reached through a symlink", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-podman-" }); - const target = yield* resolveTarget(resolverLayer([], root, path.join(root, ".supabase")), { - projectRoot: root, - runtime: "podman", - }); - expect(target).toMatchObject({ runtime: "podman", hostRunning: false }); + const { root, resolve } = yield* workspace; + const link = path.join(root, "link"); + yield* fs.symlink(root, link); + + const target = yield* resolve({ projectRoot: link, runtime: "auto" }); + + expect(target.projectRoot).toBe(root); expect(target.id).toBeUndefined(); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("selects a saved stack by the package identity tuple", () => + it.live("surfaces an unreadable saved stack instead of reporting it missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-identity-" }); - const canonicalRoot = yield* fs.realPath(root); - const identity = yield* resolveStackIdentity({ projectRoot: root, name: "feature" }).pipe( - Effect.provide(BunServices.layer), - ); - const id = "c".repeat(64); - const target = yield* resolveTarget( - resolverLayer( - [stack("d".repeat(64), { ...identity, branchContext: "other" }), stack(id, identity)], - root, - path.join(root, ".supabase"), - ), - { projectRoot: root, name: "feature", runtime: "auto" }, - ); - expect(target).toMatchObject({ projectRoot: canonicalRoot, id, name: "feature" }); + const { root, home, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root }); + yield* fs.writeFileString(path.join(home, "stacks", id, "state.json"), "{broken"); + + const failure = yield* resolve({ projectRoot: root, runtime: "auto" }).pipe(Effect.flip); + + expect(failure).toBeInstanceOf(StackTargetError); + expect(failure.reason).toBe("invalid-config"); + expect(failure.message).toContain(id); }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index a1100e561f..d7958bcc7c 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,5 +1,10 @@ -import type { ServiceCreation, StackError } from "@supabase/stack/effect"; -import { Context, Data, Effect, Layer, Option, Path } from "effect"; +import { + StackId, + type SavedStack, + type ServiceCreation, + type StackError, +} from "@supabase/stack/effect"; +import { Context, Data, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { actionability, @@ -15,15 +20,13 @@ import type { StackRuntime } from "../../../command-internal/stack-runtime.ts"; export { skippedRuntimeCleanupWarning, StackApi, stackApiLayer }; -type StackId = string; -const isStackId = (id: string): boolean => /^[0-9a-f]{64}$/u.test(id); - /** The target selected by the CLI adapter for one stack command. */ export interface StackTarget { readonly projectRoot: string; - readonly id?: StackId; + readonly id?: string; readonly name?: string; readonly runtime?: StackRuntime; + readonly definition?: SavedStack; readonly hostRunning: boolean; } @@ -65,8 +68,8 @@ export const validateStackTarget = (input: { ) : Effect.void; -const validateStackId = (id: string): Effect.Effect => - isStackId(id) +const validateStackId = (id: string): Effect.Effect => + Schema.is(StackId)(id) ? Effect.succeed(id) : Effect.fail( new StackTargetError({ @@ -97,12 +100,13 @@ const runtimeMatches = ( requested: StackTarget["runtime"], ): boolean => requested === undefined || saved === requested; -/** Resolves an existing stack by its persisted canonical package identity. */ +/** Resolves an existing stack by id or by the package identity of the project and stack name. */ export const stackTargetResolverLayer = Layer.effect( StackTargetResolver, Effect.gen(function* () { const settings = yield* CommandSettings; const stackApi = yield* StackApi; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const resolve = Effect.fn("StackTargetResolver.resolve")(function* (input: { readonly projectRoot: string; @@ -112,27 +116,19 @@ export const stackTargetResolverLayer = Layer.effect( }) { const id = input.id === undefined ? undefined : yield* validateStackId(input.id); const requestedRuntime = runtimeForFlag(input.runtime); - const identity = - id === undefined - ? yield* stackApi - .resolveIdentity({ + const stateRoot = path.join(settings.supabaseHome, "stacks"); + const found = yield* stackApi + .find( + id === undefined + ? { + stateRoot, projectRoot: input.projectRoot, ...(input.name === undefined ? {} : { name: input.name }), - }) - .pipe( - Effect.mapError( - (cause) => - new StackTargetError({ - message: cause.message, - reason: "invalid-config", - cause, - }), - ), - ) - : undefined; - const discovered = yield* stackApi - .discover({ stateRoot: path.join(settings.supabaseHome, "stacks") }) + } + : { stateRoot, id }, + ) .pipe( + Effect.map(Option.getOrUndefined), Effect.mapError( (cause) => new StackTargetError({ @@ -142,16 +138,6 @@ export const stackTargetResolverLayer = Layer.effect( }), ), ); - const found = - id === undefined - ? discovered.find( - ({ definition }) => - identity !== undefined && - definition.identity.projectRoot === identity.projectRoot && - definition.identity.branchContext === identity.branchContext && - definition.identity.stackName === identity.stackName, - ) - : discovered.find(({ definition }) => definition.id === id); if (id !== undefined && found === undefined) return yield* new StackTargetError({ message: `Stack ${id} was not found`, @@ -164,16 +150,23 @@ export const stackTargetResolverLayer = Layer.effect( "Use --runtime auto to reuse the saved runtime, or omit --stack-id and choose a different --stack name.", reason: "flags", }); + const runtime = found?.definition.runtime ?? requestedRuntime; + // A new stack saves paths under the canonical root its identity derives from. + const projectRoot = + found?.definition.identity.projectRoot ?? + (yield* fs + .realPath(input.projectRoot) + .pipe( + Effect.mapError( + (cause) => + new StackTargetError({ message: cause.message, reason: "invalid-config", cause }), + ), + )); return { - projectRoot: - found?.definition.identity.projectRoot ?? identity?.projectRoot ?? input.projectRoot, - ...(found === undefined ? {} : { id: found.definition.id }), + projectRoot, + ...(found === undefined ? {} : { id: found.definition.id, definition: found.definition }), ...(input.name === undefined ? {} : { name: input.name }), - ...(found === undefined && requestedRuntime === undefined - ? {} - : found === undefined - ? { runtime: requestedRuntime } - : { runtime: found.definition.runtime }), + ...(runtime === undefined ? {} : { runtime }), hostRunning: found?.host !== undefined, }; }); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 2e7404afb9..44364dd565 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -58,10 +58,8 @@ provides per-function values from project environment references. Per-function e entrypoints, import maps and static files are forwarded to the worker bootstrap. Configured paths are relative to `supabase/` and must remain within the project; Docker mounts that project read-only. The inspector port is retained as an endpoint intent and does not enable debugging by itself. -After an explicit stack stop, start applies changed Functions env values, per-function settings, -files root, and JWT verification when it updates the saved composition. Existing service identities, -endpoints, and lazy activation are retained. Running start calls do not refresh Functions from -changed project files; stop the stack and start it again to apply those changes. +Running start calls do not refresh Functions from changed project files; stop the stack and start +it again to apply those changes. ## Service selection @@ -72,11 +70,16 @@ Studio requires REST; excluding REST while keeping Studio fails before stopping Vector runs a stack-owned default configuration that enables its health API and forwards no service logs; log collection into Analytics is not implemented yet. -After an explicit stop, changed exclusions reuse existing service identities, data, and ports. -Removed services remain saved and stopped so including them again can reuse them. The -project configuration file is unchanged. Incompatible version, endpoint, or supported configuration -changes fail before modifying the stopped composition; they are not silently applied to saved -instances. +After an explicit stop, start compares the project configuration with the saved composition through +the stack package's composition plan, ignoring values the composition and stack credentials supply. +Changed service settings, including Functions env values, per-function settings, files root, and JWT +verification, replace the saved configuration of the existing instances; their identities, data, +and ports are retained. Changed exclusions reuse existing service identities, data, and ports. +Removed services remain saved and stopped so including them again can reuse them; a saved stopped +instance of a newly included service is reused when its endpoints and versions still match. The +project configuration file is unchanged. A changed endpoint, artifact version, or PostgreSQL major +version fails before modifying the stopped composition, naming the changed setting and suggesting +`supabase stack destroy` to recreate the stack. ## First startup and retries diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index d2ba8b57f1..353d1bee74 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,19 +1,17 @@ import { endpointReports } from "../stack-endpoints.format.ts"; -import { readStackFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; +import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; import { automaticRuntimeNotice, selectStackRuntime, } from "../../../../command-internal/stack-runtime.ts"; -import { Effect, Equal, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; +import { Effect, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; import { resolveNativePostgresUser, type Observation, + type PlannedInstance, type ServiceCreationInput, - type Stack, type StackError, - type StackIdentityInput, } from "@supabase/stack/effect"; -import { postgresVersion } from "@supabase/stack/internal/postgres-artifact"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag, @@ -22,10 +20,11 @@ import { import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { readDbToml } from "../../../../command-internal/db-config.toml-read.ts"; -import { StackCatalogSetup } from "../../../../command-internal/stack-catalog-setup.ts"; +import { catalogDatabaseServices } from "../../../../command-internal/stack-catalog-setup.ts"; import { - applyStackMigrateAndSeed, applyStackWebhooksOnly, + initializeStackDatabase, + projectCatalogOverlay, } from "../../../../command-internal/stack-bootstrap.ts"; import { stackStorageCredentialsFor } from "../../../../command-internal/stack-storage.ts"; import { @@ -99,9 +98,9 @@ const stackError = ( const loadStartConfig = (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.gen(function* () { const config = yield* loadStackConfig(projectRoot); - const identity = yield* config.identity; + const keys = yield* config.keys; const toml = yield* readDbToml(fs, path, projectRoot); - return { config, identity, toml }; + return { config, keys, toml }; }).pipe( Effect.mapError( (error) => @@ -122,92 +121,23 @@ const sameKinds = ( return leftKinds.size === rightKinds.size && [...leftKinds].every((kind) => rightKinds.has(kind)); }; -const sameBinding = ( - observed: { - readonly service: string; - readonly version?: string; - readonly config: unknown; - readonly endpoints?: unknown; - }, - requested: { - readonly service: string; - readonly version?: string; - readonly config: unknown; - readonly endpoints?: unknown; - }, -): boolean => - observed.service === requested.service && - observed.version === requested.version && - Equal.equals(observed.endpoints, requested.endpoints) && - Equal.equals( - comparableConfig(observed.service, observed.config), - comparableConfig(requested.service, requested.config), - ); - -const compositionManagedConfigKeys: Readonly>> = { - database: new Set(["databasePassword", "jwtSecret", "rootKey"]), - rest: new Set(["databaseUrl", "jwtSecret", "jwks"]), - auth: new Set(["databaseUrl", "jwtSecret", "gotrueJwtKeys", "externalApiUrl", "smtpUrl"]), - realtime: new Set(["databaseUrl", "jwtSecret", "jwks"]), - storage: new Set([ - "databaseUrl", - "filePath", - "jwtSecret", - "jwks", - "anonKey", - "serviceRoleKey", - "imgproxyUrl", - "vectorDatabaseUrl", - ]), - functions: new Set([ - "apiUrl", - "bootstrap", - "databaseUrl", - "env", - "filesRoot", - "functions", - "jwtSecret", - "jwks", - "anonKey", - "serviceRoleKey", - "publishableKey", - "secretKey", - "verifyJwt", - ]), - studio: new Set([ - "functionsRoot", - "pgmetaUrl", - "analyticsUrl", - "analyticsApiKey", - "functionsUrl", - "apiUrl", - "publicApiUrl", - "jwtSecret", - "jwks", - "anonKey", - "serviceRoleKey", - "publishableKey", - "secretKey", - ]), - analytics: new Set(["databaseUrl"]), - vector: new Set(["analyticsUrl"]), - pgmeta: new Set(["databaseUrl"]), - pooler: new Set(["databaseUrl", "jwtSecret"]), -}; - -const comparableConfig = (service: string, value: unknown): unknown => { - if (typeof value !== "object" || value === null || Array.isArray(value)) return value; - const ignored = compositionManagedConfigKeys[service] ?? new Set(); - return Object.fromEntries( - Object.entries(value) - .filter(([key]) => !ignored.has(key)) - .map(([key, entry]) => - service === "database" && key === "version" && typeof entry === "string" - ? [key, postgresVersion(entry)] - : [key, entry], - ), - ); -}; +/** Rejects a saved instance whose endpoints or artifact versions the request would change. */ +const incompatibleChange = (planned: PlannedInstance) => + planned.change !== "incompatible" + ? undefined + : planned.service === "database" && planned.paths.includes("config.version") + ? new StackCommandStartError({ + reason: "invalid-config", + message: "The requested database version does not match the saved stack binding", + suggestion: + "Keep the saved database version, or run supabase stack destroy to recreate the stack.", + }) + : new StackCommandStartError({ + reason: "invalid-config", + message: `The requested ${planned.service} ${planned.paths.join(", ")} cannot change on the saved stack`, + suggestion: + "Keep the saved endpoint and version settings, or run supabase stack destroy to recreate the stack.", + }); const selectedCreations = ( creations: ReadonlyArray, @@ -218,17 +148,6 @@ const selectedCreations = ( return !exclusions.includes(capability); }); -const compose = ( - stack: Stack, - creations: ReadonlyArray, - reuseIds: ReadonlyArray, - identity: StackIdentityInput, -) => - stack.composition.supabase(creations, { - identity, - ...(reuseIds.length === 0 ? {} : { reuseIds }), - }); - const isServing = (status: Pick) => status.lifecycle === "running" && status.health === "healthy"; @@ -420,7 +339,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags message: "A standalone database exists outside the saved stack composition", suggestion: "Destroy the standalone database before starting this stack.", }); - const { config, identity, toml } = + const { config, keys, toml } = configBeforeCreate ?? (yield* loadStartConfig(target.projectRoot, fs, path)); const creations = yield* config.creations(stack.id).pipe( Effect.mapError( @@ -432,23 +351,14 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); - const requested = yield* Effect.forEach(selectedCreations(creations, exclusions), (creation) => - creation.service === "functions" - ? readStackFunctionsEnv(`${creation.config.functionsRoot}/.env`, true).pipe( - Effect.map((env): ServiceCreationInput => ({ - ...creation, - config: { ...creation.config, env: { ...env, ...creation.config.env } }, - })), - Effect.mapError( - (cause) => - new StackCommandStartError({ - reason: "invalid-config", - message: cause.message, - cause, - }), - ), - ) - : Effect.succeed(creation), + const requested = yield* Effect.forEach( + selectedCreations(creations, exclusions), + withProjectFunctionsEnv, + ).pipe( + Effect.mapError( + (cause) => + new StackCommandStartError({ reason: "invalid-config", message: cause.message, cause }), + ), ); if ( requested.some(({ service }) => service === "studio") && @@ -502,64 +412,54 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ); const initialComposition = composition.members.length === 0; const serviceKindsChanged = !sameKinds(currentInstances, requested); - for (const instance of currentInstances) { - const creation = requested.find(({ service }) => service === instance.service); - if (creation === undefined) continue; - const status = yield* instance.status.pipe(Effect.mapError(stackError)); - if ( - creation.service === "database" && - status.config.service === "database" && - postgresVersion(creation.config.version) !== postgresVersion(status.config.config.version) - ) - return yield* new StackCommandStartError({ - reason: "invalid-config", - message: "The requested database version does not match the saved stack binding", - suggestion: "Keep the saved database version, or destroy the stack.", - }); - if (!sameBinding(status.config, creation)) - return yield* new StackCommandStartError({ - reason: "invalid-config", - message: `The requested ${creation.service} configuration does not match the saved stack binding`, - suggestion: "Keep the saved endpoint and version settings, or destroy the stack.", - }); + const planned = yield* stack.composition.plan(requested).pipe(Effect.mapError(stackError)); + for (const entry of planned) { + const rejected = entry.member ? incompatibleChange(entry) : undefined; + if (rejected !== undefined) return yield* rejected; } - const reuseIds: Array = currentInstances - .filter((instance) => requested.some((creation) => creation.service === instance.service)) - .map(({ id }) => id); - if (serviceKindsChanged) { - const currentKinds = new Set(currentInstances.map(({ service }) => service)); - for (const creation of requested) { - if (currentKinds.has(creation.service)) continue; - const candidates = []; - for (const candidate of existingServices) { - if (candidate.service !== creation.service || reuseIds.includes(candidate.id)) continue; - const status = yield* candidate.status.pipe( - Effect.map(Option.some), - Effect.catchTag("StackError", () => Effect.succeed(Option.none())), - ); - if ( - Option.isSome(status) && - status.value.lifecycle === "stopped" && - !status.value.wakeEnabled && - sameBinding(status.value.config, creation) - ) - candidates.push(candidate); - } - if (candidates.length > 1) - return yield* new StackCommandStartError({ - reason: "lifecycle", - message: `Multiple stopped ${creation.service} instances match this stack configuration`, - suggestion: "Remove the unused stack service, then retry.", - }); - const candidate = candidates[0]; - if (candidate !== undefined) reuseIds.push(candidate.id); + const reuseIds: Array = planned.filter(({ member }) => member).map(({ id }) => id); + for (const creation of requested) { + if (currentInstances.some(({ service }) => service === creation.service)) continue; + const candidates = []; + for (const candidate of planned) { + if ( + candidate.member || + candidate.service !== creation.service || + candidate.change === "incompatible" + ) + continue; + const status = yield* stack.services.get(candidate.id).pipe( + Effect.flatMap((instance) => instance.status), + Effect.map(Option.some), + Effect.catchTag("StackError", () => Effect.succeed(Option.none())), + ); + if ( + Option.isSome(status) && + status.value.lifecycle === "stopped" && + !status.value.wakeEnabled + ) + candidates.push(candidate); } + if (candidates.length > 1) + return yield* new StackCommandStartError({ + reason: "lifecycle", + message: `Multiple stopped ${creation.service} instances match this stack configuration`, + suggestion: "Remove the unused stack service, then retry.", + }); + const candidate = candidates[0]; + if (candidate !== undefined) reuseIds.push(candidate.id); } const starting = yield* output.task("Starting local Supabase stack..."); - const members = yield* compose(stack, requested, reuseIds, identity).pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); + const members = yield* stack.composition + .supabase(requested, { + keys, + eager: flags.eager, + ...(reuseIds.length === 0 ? {} : { reuseIds }), + }) + .pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError(stackError), + ); if (initialComposition) { const existingIds = new Set(existingServices.map(({ id }) => id)); const owned = members.filter(({ id }) => !existingIds.has(id)); @@ -593,27 +493,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ); } - const configured = yield* stack.composition.describe.pipe(Effect.mapError(stackError)); - const desiredMembers = configured.members.map(({ id }) => { - const member = members.find((entry) => entry.id === id); - const eager = flags.eager || member?.service === "database"; - return { - id, - activation: eager ? ("eager" as const) : ("lazy" as const), - ...(eager || member?.service === "functions" ? {} : { idleMillis: 60_000 }), - }; - }); - const activationChanged = desiredMembers.some((desired) => { - const current = configured.members.find(({ id }) => id === desired.id); - return ( - current?.activation !== desired.activation || current?.idleMillis !== desired.idleMillis - ); - }); - if (activationChanged) { - yield* stack.composition - .configure({ ...configured, members: desiredMembers }) - .pipe(Effect.mapError(stackError)); - } const database = members.find((instance) => instance.service === "database"); if (database === undefined) return yield* new StackCommandStartError({ @@ -642,70 +521,28 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags reason: "invalid-config", message: "The stack has no saved credentials", }); - const databaseServices = requested - .filter( - (creation) => - creation.service === "auth" || - creation.service === "storage" || - creation.service === "realtime", - ) - .map((creation) => creation.service); - const catalog = yield* Effect.service(StackCatalogSetup); - if (initialComposition) { - yield* catalog - .apply({ - target: { - stack, - database, - databaseServices, - }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, + if (initialComposition || serviceKindsChanged) { + const migrations = initialComposition + ? { workdir: target.projectRoot, - }, - }) - .pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); - const experimental = yield* resolveExperimentalWithProjectEnv({ ...toml.projectEnv }); - yield* applyStackMigrateAndSeed(database, target.projectRoot, toml, experimental).pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); - } else if (serviceKindsChanged) { - yield* catalog - .apply({ - target: { - stack, - database, - databaseServices, - }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir: target.projectRoot, - }, - }) - .pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); - yield* applyStackWebhooksOnly(database, toml.webhooksEnabled).pipe( + toml, + experimental: yield* resolveExperimentalWithProjectEnv({ ...toml.projectEnv }), + } + : undefined; + yield* initializeStackDatabase({ + target: { stack, database, databaseServices: catalogDatabaseServices(requested) }, + overlay: projectCatalogOverlay(toml, target.projectRoot), + ...(migrations === undefined ? {} : { migrations }), + }).pipe( Effect.tapError((error) => starting.fail(error.message)), Effect.mapError(stackError), ); - } else { + } + if (!initialComposition) yield* applyStackWebhooksOnly(database, toml.webhooksEnabled).pipe( Effect.tapError((error) => starting.fail(error.message)), Effect.mapError(stackError), ); - } if (initialComposition) { const storage = members.find((instance) => instance.service === "storage"); if (storage !== undefined) { diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index b04b38ca77..c1eb09ca72 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1,17 +1,28 @@ import { generateKeyPairSync } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { + Deferred, + Effect, + Equal, + Fiber, + FileSystem, + Layer, + Option, + Redacted, + Stream, +} from "effect"; import { DEFAULT_LOCAL_DATABASE_PASSWORD, DEFAULT_LOCAL_JWT_SECRET, DEFAULT_POSTGRES_ROOT_KEY, } from "@supabase/stack/defaults"; -import { postgresVersion } from "@supabase/stack/internal/postgres-artifact"; +import { postgresVersion } from "@supabase/stack/internal/artifacts"; import { StackError, type ServiceCreation, type ServiceCreationInput, + type PlannedInstance, type ServiceInstance, type ServiceInstances, type StackCredentials, @@ -245,15 +256,15 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { database?.service === "database" && database.config.databasePassword !== undefined ? Redacted.value(database.config.databasePassword) : DEFAULT_LOCAL_DATABASE_PASSWORD, - publishableKey: options?.identity?.publishableKey ?? "sb_publishable_test", - secretKey: options?.identity?.secretKey ?? "sb_secret_test", - anonKey: options?.identity?.anonKey ?? "anon-token", - serviceRoleKey: options?.identity?.serviceRoleKey ?? "service-token", + publishableKey: options?.keys?.publishableKey ?? "sb_publishable_test", + secretKey: options?.keys?.secretKey ?? "sb_secret_test", + anonKey: options?.keys?.anonKey ?? "anon-token", + serviceRoleKey: options?.keys?.serviceRoleKey ?? "service-token", jwks: '{"keys":[]}', - gotrueJwtKeys: options?.identity?.gotrueJwtKeys ?? "[]", - remoteJwks: options?.identity?.remoteJwks ?? "[]", - anonKeyIsOverride: options?.identity?.anonKeyIsOverride ?? false, - serviceRoleKeyIsOverride: options?.identity?.serviceRoleKeyIsOverride ?? false, + gotrueJwtKeys: options?.keys?.gotrueJwtKeys ?? "[]", + remoteJwks: options?.keys?.remoteJwks ?? "[]", + anonKeyIsOverride: options?.keys?.anonKeyIsOverride ?? false, + serviceRoleKeyIsOverride: options?.keys?.serviceRoleKeyIsOverride ?? false, }; const previousMembers = members; members = creations.map((creation) => { @@ -280,6 +291,29 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { activations = new Map(members.map(({ id }) => [id, "eager"])); return members; }), + plan: (creations) => + Effect.forEach(members, (member) => + member.status.pipe( + Effect.map(({ config }): ReadonlyArray => { + const request = creations.find(({ service }) => service === member.service); + if (request === undefined) return []; + const base = { id: member.id, service: member.service, member: true }; + if (config.service === "database" && request.service === "database") + return [ + postgresVersion(config.config.version) === postgresVersion(request.config.version) + ? { ...base, change: "unchanged" } + : { ...base, change: "incompatible", paths: ["config.version"] }, + ]; + if (!Equal.equals(config.endpoints, request.endpoints)) + return [{ ...base, change: "incompatible", paths: ["endpoints"] }]; + return [ + Equal.equals(config.config, request.config) + ? { ...base, change: "unchanged" } + : { ...base, change: "changed", paths: ["config"] }, + ]; + }), + ), + ).pipe(Effect.map((planned) => planned.flat())), configure: ({ members: configured }) => Effect.sync(() => { activations = new Map(configured.map(({ id, activation }) => [id, activation])); @@ -367,7 +401,7 @@ const layers = ( create: () => Effect.succeed(fixture.stack), open: () => Effect.succeed(fixture.stack), discover: () => Effect.succeed([]), - resolveIdentity: () => Effect.die("identity not used"), + find: () => Effect.die("identity not used"), }); return Layer.mergeAll( BunServices.layer, @@ -435,7 +469,7 @@ describe("experimental stack start", () => { }), open: () => Effect.succeed(fixture.stack), discover: () => Effect.succeed([]), - resolveIdentity: () => Effect.die("identity not used"), + find: () => Effect.die("identity not used"), }); const result = yield* stackStart(flags()).pipe( Effect.flip, @@ -614,8 +648,6 @@ describe("experimental stack start", () => { const refreshedStatus = yield* refreshed.status; if (refreshedStatus.config.service === "functions") expect(refreshedStatus.config.config.env).toEqual({ CUSTOM_VALUE: "changed" }); - const configured = yield* fixture.stack.composition.describe; - expect(configured.members.find(({ id }) => id === functionsId)?.activation).toBe("lazy"); }).pipe(Effect.provide(BunServices.layer)), ); @@ -917,6 +949,59 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("applies changed service configuration after the stack is stopped", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-changed-config-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + const config = (maxRows: number) => + `project_id = "changed-config"\n[api]\nmax_rows = ${maxRows}\n[edge_runtime]\nenabled = false\n`; + yield* fs.writeFileString(`${root}/supabase/config.toml`, config(100)); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const restId = fixture.members.find(({ service }) => service === "rest")?.id; + + yield* fs.writeFileString(`${root}/supabase/config.toml`, config(500)); + yield* fixture.stack.composition.stop; + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + const rest = fixture.members.find(({ service }) => service === "rest"); + if (rest === undefined) return yield* Effect.die("REST missing"); + expect(rest.id).toBe(restId); + const status = yield* rest.status; + expect(status.config.service === "rest" ? status.config.config.maxRows : undefined).toBe(500); + expect(fixture.composed).toBe(2); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("rejects a changed endpoint after the stack is stopped", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-changed-port-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "changed-port"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "changed-port"\n[api]\nport = 54999\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("cannot change on the saved stack"), + suggestion: expect.stringContaining("supabase stack destroy"), + }); + expect(fixture.composed).toBe(1); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("matches a Postgres major alias to the saved pinned database version", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts index 8ff71a8172..b1ab5732b7 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts @@ -145,8 +145,8 @@ enabled = false enabled = false `; -const makeLayers = (root: string, apiLayer = liveStackApi) => { - const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); +const makeLayers = (root: string, apiLayer = liveStackApi, workdir = root) => { + const settings = mockCommandSettings({ workdir, supabaseHome: root }); const resolver = stackTargetResolverLayer.pipe( Layer.provide(Layer.mergeAll(BunServices.layer, settings, apiLayer)), ); @@ -436,4 +436,65 @@ describe("experimental stack start native lifecycle", () => { }).pipe(Effect.provide(BunServices.layer)), { timeout: 60_000 }, ); + + it.live.skipIf(process.platform === "win32")( + "restarts a stack created through a symlinked workdir after it is stopped", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-symlink-" }), + ); + const project = path.join(root, "project"); + const workdir = path.join(root, "workdir"); + yield* fs.makeDirectory(path.join(project, "supabase"), { recursive: true }); + yield* fs.symlink(project, workdir); + yield* fs.writeFileString( + path.join(project, "supabase", "config.toml"), + projectConfig.replace( + "[edge_runtime]\nenabled = false", + "[edge_runtime]\nenabled = true", + ), + ); + const fixture = makeLayers(root, liveStackApi, workdir); + const onlyFunctions = excluded.filter((name) => name !== "functions"); + yield* Effect.ensuring( + Effect.gen(function* () { + const api = yield* StackApi; + const locations = { + stateRoot: path.join(root, "stacks"), + cacheRoot: path.join(root, "cache"), + }; + const functionsRoot = Effect.fn("functionsRoot")(function* (id: string) { + const stack = yield* api.open({ ...locations, id }); + const functions = (yield* stack.services.list).find( + (instance) => instance.service === "functions", + ); + if (functions === undefined) return yield* Effect.die("Functions missing"); + const status = yield* functions.status; + return { + id: functions.id, + root: + status.config.service === "functions" ? status.config.config.functionsRoot : "", + }; + }); + const stackId = yield* stackStart(flags(onlyFunctions)); + const first = yield* functionsRoot(stackId); + expect(first.root.startsWith(project)).toBe(true); + yield* (yield* api.open({ ...locations, id: stackId })).stop; + + const restartedId = yield* stackStart(flags(onlyFunctions)); + + expect(restartedId).toBe(stackId); + expect(yield* functionsRoot(restartedId)).toEqual(first); + }), + Effect.gen(function* () { + const api = yield* StackApi; + yield* destroyTestStacks(api, path.join(root, "stacks"), path.join(root, "cache")); + }), + ).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(BunServices.layer)), + { timeout: 180_000 }, + ); }); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index e838fb9aaf..90d243b0db 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -21,14 +21,15 @@ members remain visible without live lifecycle or health values. Lifecycle, health, endpoint, and aggregate readiness values are reported separately so a stopped or unhealthy member is distinguishable from an unavailable owner. -The command compares the database version and explicit numeric endpoint ports with the saved -configuration of existing composition members. It does not compare other service settings or -changes to membership; an excluded service is not considered drift. Live listener availability -does not affect this comparison. It reports `config_drift.status` as `unchanged` or -`changed`, with changed paths, when the comparison is possible. A configuration -loading failure or unavailable database observation keeps the saved stack report -available and is shown as `config_drift.status: "unavailable"` with a message in -JSON. Status does not apply current configuration. The `services` list may include +The command compares the project configuration with the saved configuration of existing +composition members through the stack package's composition plan. It reads +`supabase/functions/.env` only when Functions is a saved composition member. +Values that the composition or stack credentials supply are ignored, as are changes to membership; +an excluded service is not considered drift. The comparison reads saved state only, so it is +available while the owner is unavailable. It reports `config_drift.status` as `unchanged` or +`changed`, with `services..` paths, when the comparison is possible. A configuration +loading failure or unreadable saved state keeps the saved stack report available and is shown as +`config_drift.status: "unavailable"` with a message in JSON. Status does not apply current configuration. The `services` list may include saved standalone instances; composition members identify the services used for primary database, environment export, and drift comparisons. diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 21ddf8f87e..b89ed5717a 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -1,17 +1,13 @@ import { endpointReports } from "../stack-endpoints.format.ts"; import { Effect, Option, Path, Redacted } from "effect"; -import type { - Observation, - ServiceCreation, - ServiceCreationInput, - Stack, -} from "@supabase/stack/effect"; +import type { Observation, PlannedInstance, ServiceCreation, Stack } from "@supabase/stack/effect"; import type { StackError } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; +import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; import { toPostgresURL } from "../../../../command-internal/postgres-url.ts"; import { StackApi, @@ -258,10 +254,7 @@ const findTarget = Effect.fn("experimental.stack.status.findTarget")(function* ( name: string | undefined, id: string | undefined, ) { - const settings = yield* CommandSettings; - const path = yield* Path.Path; const resolver = yield* StackTargetResolver; - const api = yield* StackApi; const target = yield* resolver .resolve({ projectRoot, @@ -270,27 +263,17 @@ const findTarget = Effect.fn("experimental.stack.status.findTarget")(function* ( runtime: "auto", }) .pipe(Effect.mapError(mapTargetError)); - if (target.id === undefined) + if (target.id === undefined || target.definition === undefined) return yield* new StackCommandStatusError({ reason: "not-found", message: "No managed stack exists for the selected project.", suggestion: "Run supabase stack start first.", }); - const discovered = yield* api - .discover({ stateRoot: path.join(settings.supabaseHome, "stacks") }) - .pipe(Effect.mapError(mapStackError)); - const found = discovered.find(({ definition }) => definition.id === target.id); - if (found === undefined) - return yield* new StackCommandStatusError({ - reason: "not-found", - message: `Stack ${target.id} was not found.`, - suggestion: "Choose an existing --stack-id, or run supabase stack start first.", - }); return { ...target, id: target.id, - definition: found.definition, - owner: found.host === undefined ? ("unavailable" as const) : ("reachable" as const), + definition: target.definition, + owner: target.hostRunning ? ("reachable" as const) : ("unavailable" as const), }; }); @@ -311,59 +294,16 @@ const observe = (stack: Stack, owner: StackReport["owner"]) => return { observed, members: composition.members }; }); -const compareConfig = ( - creations: ReadonlyArray, - observed: ReadonlyArray, - members: ReadonlyArray<{ readonly id: string }>, -): StackReport["config_drift"] => { - const paths: string[] = []; - const memberIds = new Set(members.map(({ id }) => id)); - for (const creation of creations) { - const service = observed.find( - ({ instance }) => memberIds.has(instance.id) && instance.service === creation.service, - ); - if (service === undefined) continue; - if (service.observation === undefined) { - if (service?.error !== undefined) - return { - status: "unavailable", - message: `Configuration could not be compared because ${creation.service} status failed: ${service.error.message}`, - }; - paths.push(`services.${creation.service}`); - continue; - } - if ( - creation.service === "database" && - service.observation.config.service === "database" && - creation.config.version !== service.observation.config.config.version - ) - paths.push(`services.database.config.version`); - if (creation.endpoints === undefined) continue; - for (const name of Object.keys(creation.endpoints)) { - const endpoint = Reflect.get(creation.endpoints, name); - if ( - typeof endpoint !== "object" || - endpoint === null || - !("port" in endpoint) || - typeof endpoint.port !== "number" - ) - continue; - const savedEndpoints = service.observation.config.endpoints; - const actual = savedEndpoints === undefined ? undefined : Reflect.get(savedEndpoints, name); - if ( - typeof actual !== "object" || - actual === null || - !("port" in actual) || - actual.port !== endpoint.port - ) - paths.push(`services.${creation.service}.endpoints.${name}`); - } - } +const driftFrom = (planned: ReadonlyArray): StackReport["config_drift"] => { + const paths = planned.flatMap((entry) => + !entry.member || entry.change === "unchanged" + ? [] + : entry.paths.map((path) => `services.${entry.service}.${path}`), + ); return paths.length === 0 ? { status: "unchanged", - message: - "Configured database version and explicit endpoint ports match existing composition members.", + message: "Project configuration matches the saved composition members.", } : { status: "changed", @@ -372,33 +312,35 @@ const compareConfig = ( }; }; -const configDrift = ( - projectRoot: string, - stackId: string, - observed: ReadonlyArray, - members: ReadonlyArray<{ readonly id: string }>, -) => +const unavailableDrift = (message: string): StackReport["config_drift"] => ({ + status: "unavailable", + message, +}); + +const configDrift = (stack: Stack, projectRoot: string, functionsIsMember: boolean) => Effect.gen(function* () { - const memberIds = new Set(members.map(({ id }) => id)); - const database = observed.find( - ({ instance }) => memberIds.has(instance.id) && instance.service === "database", - ); - const databaseObservation = database?.observation; - if (databaseObservation?.config.service !== "database") - return { - status: "unavailable" as const, - message: "Configuration could not be compared without the saved database observation.", - }; const loaded = yield* loadStackConfig(projectRoot); - const creations = yield* loaded.creations(stackId); - return compareConfig(creations, observed, members); + const creations = yield* loaded.creations(stack.id); + // Drift ignores non-members, so a Functions dotenv only matters when Functions is a member. + const requested = functionsIsMember + ? yield* Effect.forEach(creations, withProjectFunctionsEnv) + : creations; + return driftFrom(yield* stack.composition.plan(requested)); }).pipe( - Effect.catchTag("StackConfigError", (error) => - Effect.succeed({ - status: "unavailable" as const, - message: `Project configuration could not be compared: ${error.message}`, - }), - ), + Effect.catchTags({ + StackConfigError: (error) => + Effect.succeed( + unavailableDrift(`Project configuration could not be compared: ${error.message}`), + ), + StackFunctionsEnvError: (error) => + Effect.succeed( + unavailableDrift(`Project configuration could not be compared: ${error.message}`), + ), + StackError: (error) => + Effect.succeed( + unavailableDrift(`Saved configuration could not be compared: ${error.message}`), + ), + }), ); export const stackStatus = Effect.fn("experimental.stack.status")(function* ( @@ -445,8 +387,8 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( .open({ ...locations, id: target.id }) .pipe(Effect.mapError(mapStackError)); const observed = yield* observe(stack, target.owner); + const memberIds = new Set(observed.members.map(({ id }) => id)); if (flags.env) { - const memberIds = new Set(observed.members.map(({ id }) => id)); const database = observed.observed.find( ({ instance }) => memberIds.has(instance.id) && instance.service === "database", ); @@ -509,10 +451,11 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( return; } const config = yield* configDrift( + stack, target.definition.identity.projectRoot, - target.definition.id, - observed.observed, - observed.members, + observed.observed.some( + ({ instance }) => memberIds.has(instance.id) && instance.service === "functions", + ), ); const report = reportFor( target.definition, diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 2c1b7bd86b..e9dd500ff5 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Stream } from "effect"; import { type Observation, + type PlannedInstance, type ServiceCreation, type StackCredentials, StackError, @@ -48,7 +49,16 @@ const database: ServiceCreation = { }; const rest: ServiceCreation = { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, + endpoints: { http: { port: 54321 } }, +}; +const functions: ServiceCreation = { + service: "functions", + config: { + functionsRoot: "/project/supabase/functions", + bootstrap: "export default {};", + verifyJwt: true, + }, endpoints: { http: { port: 54321 } }, }; const flags = (input?: Partial): StackStatusFlags => ({ @@ -119,6 +129,7 @@ const makeStack = ( services: ReadonlyArray, members: ReadonlyArray<{ readonly id: string; readonly activation: "eager" | "lazy" }>, credentials: StackCredentials = savedCredentials, + planned: ReadonlyArray = [], ): OpenedStack => ({ id: stackId, services: { @@ -128,6 +139,7 @@ const makeStack = ( }, credentials: { get: Effect.succeed(credentials) }, composition: { + plan: () => Effect.succeed(planned), supabase: (_services, _options) => Effect.die("unused"), configure: (_config) => Effect.die("unused"), describe: Effect.succeed({ members: [...members], dependencies: [] }), @@ -147,8 +159,9 @@ const runStatus = (input: { readonly members?: ReadonlyArray<{ readonly id: string; readonly activation: "eager" | "lazy" }>; readonly reachable?: boolean; readonly outputFormat?: StatusOutputFormat; - readonly config?: "missing" | "invalid" | "explicit"; + readonly config?: "missing" | "invalid" | "explicit" | "multiline-functions-env"; readonly stackCredentials?: StackCredentials; + readonly planned?: ReadonlyArray; readonly flags?: StackStatusFlags; }) => Effect.gen(function* () { @@ -165,11 +178,23 @@ const runStatus = (input: { 'project_id = "status-test"\n[db]\nport = 54322\n', ); } + if (input.config === "multiline-functions-env") { + yield* fs.makeDirectory(`${root}/supabase/functions`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "status-test"\n[edge_runtime]\nenabled = true\n', + ); + yield* fs.writeFileString( + `${root}/supabase/functions/.env`, + 'PRIVATE_KEY="-----BEGIN KEY-----\nsecret\n-----END KEY-----"\n', + ); + } const projectRoot = root; const stack = makeStack( input.services, input.members ?? input.services.map(({ id }) => ({ id, activation: "lazy" as const })), input.stackCredentials, + input.planned, ); const definition = { id: stackId, @@ -190,23 +215,8 @@ const runStatus = (input: { const api = Layer.succeed(StackApi, { create: () => Effect.die("create must not run"), open: () => Effect.succeed(stack), - discover: () => - Effect.succeed([ - { - definition, - host: - input.reachable === false - ? undefined - : { - stackId, - identity: definition.identity, - pid: 123, - port: 4567, - release: "test", - }, - }, - ]), - resolveIdentity: () => Effect.succeed(definition.identity), + discover: () => Effect.die("discover must not run"), + find: () => Effect.die("find must not run"), }); const resolver = Layer.succeed(StackTargetResolver, { resolve: (target) => @@ -214,7 +224,8 @@ const runStatus = (input: { projectRoot: target.projectRoot, id: stackId, runtime: "native" as const, - hostRunning: false, + definition, + hostRunning: input.reachable !== false, }), }); const out = mockOutput({ format: input.outputFormat ?? "text" }); @@ -239,7 +250,7 @@ it.live("reports observed lifecycle and health without requesting credentials", const authCalls = { value: 0 }; const auth: ServiceCreation = { service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + config: { jwtSecret }, endpoints: { http: { port: 54325 } }, }; const services = [ @@ -382,7 +393,7 @@ it.live("reports stopped readiness without treating unbound endpoints as drift", }), ); -it.live("does not report port drift when a stopped service has no live binding", () => +it.live("reports the planned differences of composition members as drift", () => Effect.gen(function* () { const run = yield* runStatus({ services: [ @@ -396,43 +407,63 @@ it.live("does not report port drift when a stopped service has no live binding", }), }), ], - config: "explicit", - reachable: true, - outputFormat: "json", - }); - yield* run.effect; - const result = run.out.messages.find((message) => message.type === "success")?.data; - expect(result).toMatchObject({ config_drift: { status: "unchanged" } }); - }), -); - -it.live("reports changed explicit ports even while a service is stopped", () => - Effect.gen(function* () { - const changed = { ...database, endpoints: { sql: { port: 54329 } } }; - const run = yield* runStatus({ - services: [ - makeService({ + members: [{ id: "database-id", activation: "eager" }], + planned: [ + { id: "database-id", - creation: changed, - statusCalls: { value: 0 }, - observation: makeObservation("database-id", changed, { - lifecycle: "stopped", - wakeEnabled: false, - }), - }), + service: "database", + member: true, + change: "incompatible", + paths: ["endpoints.sql.port"], + }, + { + id: "standalone-rest", + service: "rest", + member: false, + change: "changed", + paths: ["config.maxRows"], + }, ], config: "explicit", - reachable: true, + reachable: false, outputFormat: "json", }); yield* run.effect; const result = run.out.messages.find((message) => message.type === "success")?.data; expect(result).toMatchObject({ - config_drift: { status: "changed", paths: ["services.database.endpoints.sql"] }, + config_drift: { status: "changed", paths: ["services.database.endpoints.sql.port"] }, }); }), ); +for (const { functionsMember, status } of [ + { functionsMember: false, status: "unchanged" }, + { functionsMember: true, status: "unavailable" }, +] as const) + it.live( + `reports ${status} drift for a multiline Functions dotenv when Functions is ${functionsMember ? "" : "not "}a member`, + () => + Effect.gen(function* () { + const services = [ + makeService({ id: "database-id", creation: database, statusCalls: { value: 0 } }), + makeService({ id: "functions-id", creation: functions, statusCalls: { value: 0 } }), + ]; + const run = yield* runStatus({ + services, + members: [ + { id: "database-id", activation: "eager" }, + ...(functionsMember ? [{ id: "functions-id", activation: "eager" as const }] : []), + ], + config: "multiline-functions-env", + reachable: false, + outputFormat: "json", + }); + yield* run.effect; + const result = run.out.messages.find((message) => message.type === "success")?.data; + expect(result).toMatchObject({ config_drift: { status } }); + }), + ); + it.live("reports unavailable owner and does not query service status", () => Effect.gen(function* () { const statusCalls = { value: 0 }; diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index 845e99b5ed..a2bd679794 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -19,10 +19,12 @@ after that preflight, the shutdown error remains a failure. Text confirms each successful shutdown and identifies each unavailable owner. JSON and stream-json success data contain `stopped` and `unavailable` ID arrays. A missing default selection reports `found: false`; an unknown explicit name or -ID fails. `--all` attempts every selected reachable owner and reports failures -with their IDs. State entries that cannot be read or decoded are skipped with a -warning on stderr identifying each stack; only a failure to read the stacks -directory itself fails discovery. +ID fails. A single selection reads only the selected stack's state document; an +unreadable document fails the selection instead of being reported as missing. +`--all` attempts every selected reachable owner and reports failures with their +IDs. It skips state entries that cannot be read or decoded with a warning on +stderr identifying each stack; only a failure to read the stacks directory +itself fails discovery. ## Files and network diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 4f0cab3132..93477c1335 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -79,7 +79,31 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: }), ), ); - if (!all && target?.id === undefined) { + const locations = { + stateRoot: path.join(settings.supabaseHome, "stacks"), + cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), + }; + const selected = + target === undefined + ? yield* api + .discover({ + ...locations, + onInvalidState: (id, error) => + output.raw(`Warning: skipping invalid stack ${id}: ${error.message}\n`, "stderr"), + }) + .pipe( + Effect.map((discovered) => + discovered.map(({ definition, host }) => ({ + id: definition.id, + hostRunning: host !== undefined, + })), + ), + Effect.mapError(stopError), + ) + : target.id === undefined + ? undefined + : [{ id: target.id, hostRunning: target.hostRunning }]; + if (selected === undefined) { if (Option.isSome(flags.stack)) return yield* new StackCommandStopError({ reason: "flags", @@ -88,42 +112,23 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: yield* output.success("No managed stack found for this context.", { found: false }); return; } - const locations = { - stateRoot: path.join(settings.supabaseHome, "stacks"), - cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), - }; - const discovered = yield* api - .discover({ - ...locations, - onInvalidState: (id, error) => - output.raw(`Warning: skipping invalid stack ${id}: ${error.message}\n`, "stderr"), - }) - .pipe(Effect.mapError(stopError)); - const selected = all - ? discovered - : discovered.filter(({ definition }) => definition.id === target?.id); - if (!all && selected.length === 0) - return yield* new StackCommandStopError({ - reason: "flags", - message: "The selected stack no longer exists.", - }); const results = yield* Effect.forEach( selected, ({ - definition, - host, + id, + hostRunning, }): Effect.Effect<{ readonly id: string; readonly result: Result.Result<"stopped" | "unavailable", StackError>; }> => - host === undefined - ? Effect.succeed({ id: definition.id, result: Result.succeed("unavailable" as const) }) - : api.open({ ...locations, id: definition.id }).pipe( + !hostRunning + ? Effect.succeed({ id, result: Result.succeed("unavailable" as const) }) + : api.open({ ...locations, id }).pipe( Effect.flatMap((stack) => stack.stop), Effect.scoped, Effect.as("stopped" as const), Effect.result, - Effect.map((result) => ({ id: definition.id, result })), + Effect.map((result) => ({ id, result })), ), ); const failures = results.flatMap(({ id, result }) => diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index ea155b96bd..6648036cf2 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -110,9 +110,9 @@ describe("stack stop", () => { StackApi, StackApi.of({ ...f.api, - discover: () => - Effect.succeed([ - { + find: () => + Effect.succeed( + Option.some({ ...saved, host: { stackId: f.stack.id, @@ -121,8 +121,8 @@ describe("stack stop", () => { port: 1, release: "test", }, - }, - ]), + }), + ), open: () => Effect.succeed({ ...f.stack, diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 6b2a157576..4c1ce7d9a7 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -78,7 +78,7 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( }, { service: "rest", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + config: { jwtSecret }, endpoints: { http: { port: "auto" } }, }, ...(included diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index cf37d70309..45f486e1cd 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -266,6 +266,7 @@ const fixture = ( }, credentials: { get: Effect.succeed(stackCredentials) }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed({ members: options.standaloneProjectRoot === undefined @@ -290,22 +291,22 @@ const fixture = ( const apiService = { create: () => Effect.die("unused"), open: () => Effect.succeed(stack), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: stack.id, identity, - runtime: "native", + runtime: "native" as const, instances: [], - lifetime: "detached", + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }, host: undefined, - }, - ]), - resolveIdentity: () => Effect.succeed(identity), + }), + ), } satisfies StackApi["Service"]; const api = Layer.succeed(StackApi, apiService); const layer = Layer.mergeAll( diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts index 85907a4470..aa3017cb9d 100644 --- a/apps/cli/src/commands/services/services-local-stack.ts +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -2,7 +2,7 @@ import { artifactServiceKinds, postgresVersion, resolveArtifact, -} from "@supabase/stack/internal/service-catalog"; +} from "@supabase/stack/internal/artifacts"; import { Effect, Result } from "effect"; import { loadLocalProjectContext } from "../../command-internal/local-project-context.ts"; import { envOverrideMajorVersion } from "../../command-internal/local-config-values.ts"; diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 35b6f6bad3..5a5a1f2479 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -128,7 +128,6 @@ const databaseCreation: Extract = { const storageCreation: Extract = { service: "storage", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: STORAGE_TEST_JWT_SECRET, serviceRoleKey: stackCredentials.serviceRoleKey, filePath: "/tmp/storage", @@ -254,6 +253,7 @@ export function buildStorageStackApi( }, credentials: { get: Effect.succeed(stackCredentials) }, composition: { + plan: () => Effect.succeed([]), supabase: () => Effect.die("unused"), configure: () => Effect.die("unused"), describe: Effect.succeed({ members, dependencies: [] }), @@ -284,13 +284,17 @@ export function buildStorageStackApi( create: () => Effect.die("Service not found: supabase/stack/StackApi"), open: () => Effect.die("Service not found: supabase/stack/StackApi"), discover: () => Effect.die("Service not found: supabase/stack/StackApi"), - resolveIdentity: () => Effect.die("Service not found: supabase/stack/StackApi"), + find: () => Effect.die("Service not found: supabase/stack/StackApi"), }) : Layer.succeed(StackApi, { create: () => Effect.die("unused"), - resolveIdentity: () => Effect.succeed(definition.identity), - discover: () => - Effect.succeed(options.found === false ? [] : [{ definition, host: undefined }]), + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + options.found === false + ? Option.none() + : Option.some({ definition, host: undefined }), + ), open: () => { findStackCalls.push({ projectRoot: workdir }); return Effect.succeed(stack); diff --git a/apps/cli/tests/helpers/unused-stack.ts b/apps/cli/tests/helpers/unused-stack.ts index fdbf9ba205..ea1039f398 100644 --- a/apps/cli/tests/helpers/unused-stack.ts +++ b/apps/cli/tests/helpers/unused-stack.ts @@ -9,7 +9,7 @@ export const unusedStackServices = Layer.mergeAll( create: unused, open: unused, discover: unused, - resolveIdentity: unused, + find: unused, }), Layer.succeed(StackCatalogSetup, { apply: unused }), ); diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 9bdf25fe02..26629a8355 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -723,13 +723,15 @@ A Functions runtime exit must reach `followStatus` so serve can report it. ### Established CLI integration boundaries -The Stack package remains the owner of identity semantics. It canonicalizes `projectRoot`, resolves the Git branch context (or ordinary-workspace fallback), and validates the stack name in [`Identity.ts`](./src/identity/Identity.ts); it also owns `deriveStackId` from that complete tuple. The CLI currently calls `resolveStackIdentity` through the internal [`identity` entrypoint](./src/identity/Identity.ts), then uses the result when matching `discover` records for status and related read operations. Resolving identity is read-only and does not create a stack. A follow-up recommendation is to expose an equivalent public, read-only `resolveIdentity` operation so the CLI need not import an internal entrypoint; this is a recommended public API, not an existing export. +The Stack package remains the owner of identity semantics. It canonicalizes `projectRoot`, resolves the Git branch context (or ordinary-workspace fallback), and validates the stack name in [`Identity.ts`](./src/identity/Identity.ts); it also owns `deriveStackId` from that complete tuple and the `StackId` format. The CLI selects a stack with the public, read-only `find`, by project root and stack name or by ID; `find` derives the ID, reads only that state document, and reports the live owner. It never creates a stack, and an unreadable document fails selection rather than reading as absent. `discover` remains the listing operation for `stack list` and `stack stop --all`. + +Composition policy stays in the package. `composition.supabase` owns the managed bindings, the inputs derived from the API endpoint and the stack credentials, and the activation policy; the CLI passes `--eager` as the `eager` preference. Creation schemas make every bound or injected input optional, and a launch without a required input fails with a typed `ServiceError` naming it. Before recomposing a stopped stack, the CLI calls `composition.plan`, which compares requested creations with the saved instances while ignoring package-managed inputs and normalising the shared API port as composition does. The CLI rejects `incompatible` members, recomposes `changed` members with their new configuration under the same identities, and reuses stopped standalone instances that are not incompatible. `stack status` reports the same plan as configuration drift. Required inputs are checked after dependency inputs are merged and before a start or restart changes lifecycle, so a restart without one leaves the instance running. The Functions recipe publishes a default Edge Runtime main service built from [`serve.main.ts`](./src/functions/serve.main.ts). [`generate-functions-bootstrap.ts`](./scripts/generate-functions-bootstrap.ts) bundles it, with its dependencies inlined for offline use, into the committed module [`serve-main-bundle.ts`](./src/functions/generated/serve-main-bundle.ts); `pnpm generate` refreshes it and a unit test fails when it drifts from the sources. A creation may override it with `bootstrap`; the default is not saved in the stack document. `stack start` and the stack-backed `functions serve` command use the default. The CLI owns the foreground `functions serve` session. It attaches to an existing composition member and leaves it available on exit. Supported explicit overrides replace its configuration for the session, then restore it on normal cleanup. If Functions is excluded, the CLI creates and later destroys one standalone instance without changing composition. The package needs no session or recovery API: ordinary create, start, restart, status, logs, and destroy suffice. Functions accepts custom environment values and a database URL; its recipe derives default keys, while the composer supplies the runtime database URL without a dependency edge. See the [command lifecycle](../../apps/cli/docs/stack-commands.md) for supported flags and cleanup limits. -PostgreSQL artifact knowledge remains in Stack and is exposed through [`postgres-artifact.ts`](./src/internal/postgres-artifact.ts), including catalog resolution and native artifact preparation and verification. A remote `db dump --db-url` can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by [`bundled-postgres-client.ts`](../../apps/cli/src/command-internal/bundled-postgres-client.ts), while managed jobs continue to use `stack.commands.run`. +Artifact knowledge remains in Stack and is exposed to the CLI through the single internal [`artifacts` entrypoint](./src/internal/artifacts.ts), including the service catalog, PostgreSQL version resolution, and native artifact preparation and verification. A remote `db dump --db-url` can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by [`bundled-postgres-client.ts`](../../apps/cli/src/command-internal/bundled-postgres-client.ts), while managed jobs continue to use `stack.commands.run`. Changing internal and public-to-repository contracts is acceptable when callers are updated; preserving valuable data is still required. Keep per-instance configuration replacement through `service.restart({ config })`. Validate the candidate configuration and prepare its artifacts before stopping the existing runtime; invalid input must leave it running. The admitted restart then performs ordinary stop and launch without waiting for application health inside the gate. Adding or removing a companion means explicitly adding or removing an ordinary instance and updating composition edges. Validate the graph using the same rules as registration; do not implement private-child expansion or group replacement logic. Defer live shared configuration changes and config-bearing whole-stack restart. diff --git a/packages/stack/README.md b/packages/stack/README.md index 792e5bf956..169d6f7b62 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -67,6 +67,8 @@ On Linux, native Functions project files must be outside `/tmp`: Edge Runtime us `open({ id, stateRoot, cacheRoot })` reconnects to a saved stack. The package stores the stack document at `//state.json` and service data at `//data/`. `discover({ stateRoot })` lists saved definitions and port assignments separately from live-owner availability. It skips each entry that cannot be read or decoded and reports it to `onInvalidState(id, error)`; only a failure to read `stateRoot` itself fails discovery. Port allocation skips the same entries. Offline definitions are not live lifecycle observations. +`find({ stateRoot, projectRoot, name })` derives the stack ID with the same identity rules as `create` and reads only that stack; `find({ stateRoot, id })` reads a known ID. It returns the saved definition with the live owner's endpoint, if any, or nothing when no such stack is saved. Unlike `discover`, an unreadable state document fails the call instead of being skipped. The Effect entrypoint's `StackId` schema validates an ID before lookup. + Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. The owner writes its output to `//owner.log`, which each owner start truncates; owner start and connection failures name that file. A client drives only an owner of its own release; other operations fail and ask you to stop or destroy the stack, which work across releases. ### Lifetimes @@ -105,14 +107,18 @@ const services = await stack.composition.supabase([ }, { service: "rest", - config: { databaseUrl: "postgresql://configured-by-composition" }, + config: {}, endpoints: { http: { port: "auto" } }, }, ]); await stack.composition.start(); ``` -The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Functions are lazy without an idle timeout; other public services are lazy with a 60-second idle timeout. Services without public endpoints are eager. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition. +The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Functions are lazy without an idle timeout; other public services are lazy with a 60-second idle timeout. Services without public endpoints are eager. Pass `{ eager: true }` to start every member eagerly. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition. + +Inputs that the composition binds or the owner fills from the stack credentials, such as REST's `databaseUrl` or a JWT secret, are optional in creation configuration. Starting or restarting an instance without a required input that was neither bound nor provided fails before any lifecycle change with a `ServiceError` whose `operation` is `"input"` and whose message names the input; a running instance keeps running. + +To recompose a stopped stack, pass the IDs to keep in `reuseIds`. `composition.plan(services)` compares requested creations with every saved instance of the same kinds without contacting the owner or changing state. Each entry reports its instance `id`, `service`, whether it is a composition `member`, and a `change`: `unchanged`, `changed` with the differing config `paths`, or `incompatible` with the `paths` of an endpoint, artifact version, or PostgreSQL major-version change that the saved instance cannot adopt. Inputs that the composition or the stack credentials supply are ignored, an automatic API port is compared as the shared fixed port the composition would assign, and a PostgreSQL major alias matches its pinned version. Recomposing with `reuseIds` replaces a `changed` instance's configuration while keeping its identity, data, and ports. The whole-stack E2E suite covers the default lazy lifecycle and reopen, all-eager startup, idle and wake, and parallel stack isolation for native and Docker runtimes. Run one runtime with `pnpm --filter @supabase/stack test:e2e:run src/whole-stack.native.e2e.test.ts` or the corresponding Docker test file. @@ -121,7 +127,7 @@ For multiple instances or custom dependencies, configure members and bindings ex ```ts const rest = await stack.services.create({ service: "rest", - config: { databaseUrl: "postgresql://configured-by-composition" }, + config: {}, endpoints: { http: { port: "auto" } }, }); diff --git a/packages/stack/package.json b/packages/stack/package.json index 92a5beee24..01df1442b9 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -9,10 +9,8 @@ "./defaults": "./src/Defaults.ts", "./commands": "./src/Commands.ts", "./internal/dispatch": "./src/internal/dispatch.ts", - "./internal/identity": "./src/identity/Identity.ts", - "./internal/postgres-artifact": "./src/internal/postgres-artifact.ts", - "./internal/release": "./src/internal/release.ts", - "./internal/service-catalog": "./src/internal/service-catalog.ts" + "./internal/artifacts": "./src/internal/artifacts.ts", + "./internal/release": "./src/internal/release.ts" }, "scripts": { "generate": "bun run scripts/generate-functions-bootstrap.ts", diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 120b8a0697..e5957f3769 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -10,7 +10,7 @@ import { OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; import { DEFAULT_LOCAL_JWT_SECRET } from "./Defaults.ts"; -import { ServiceCreation } from "./services/Catalog.ts"; +import { ServiceCreation, type ServiceCreationInput } from "./services/Catalog.ts"; import { ownerFor } from "../tests/owner-rpc.ts"; const stateFor = (root: string) => @@ -100,7 +100,7 @@ it.live("forwards and rotates saved identity across composed services in one own }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const servicesFor = (jwtSecret?: string) => [ + const servicesFor = (jwtSecret?: string): ReadonlyArray => [ { service: "database" as const, config: { @@ -113,22 +113,22 @@ it.live("forwards and rotates saved identity across composed services in one own }, { service: "rest" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }, { service: "auth" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }, { service: "storage" as const, - config: { databaseUrl: "postgresql://placeholder", filePath: `${root}/uploads` }, + config: { filePath: `${root}/uploads` }, endpoints: { http: { port: "auto" as const } }, }, { service: "realtime" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }, { @@ -142,12 +142,12 @@ it.live("forwards and rotates saved identity across composed services in one own endpoints: { http: { port: "auto" as const } }, }, ]; - const identity = (suffix: string, keys: string): State.StackIdentityInput => ({ + const stackKeys = (suffix: string, gotrueJwtKeys: string): State.StackIdentityInput => ({ publishableKey: `publishable-${suffix}`, secretKey: `secret-${suffix}`, anonKey: `anon-${suffix}`, serviceRoleKey: `service-role-${suffix}`, - gotrueJwtKeys: keys, + gotrueJwtKeys, publicSigningKeys: "[]", anonKeyIsOverride: true, serviceRoleKeyIsOverride: true, @@ -156,7 +156,7 @@ it.live("forwards and rotates saved identity across composed services in one own const services = servicesFor(customJwtSecret); const first = yield* owner.rpc.supabaseComposition({ services: services, - identity: identity("one", "[]"), + keys: stackKeys("one", "[]"), }); const ids = first.map(({ id }) => id); const creationFor = (entries: typeof first, service: string) => @@ -206,7 +206,7 @@ it.live("forwards and rotates saved identity across composed services in one own const standaloneRest = yield* owner.rpc.createService({ service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: {}, }); expect(standaloneRest.creation.config).toMatchObject({ @@ -215,7 +215,7 @@ it.live("forwards and rotates saved identity across composed services in one own }); const standaloneAuth = yield* owner.rpc.createService({ service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: {}, }); expect(standaloneAuth.creation.config).toMatchObject({ @@ -224,7 +224,7 @@ it.live("forwards and rotates saved identity across composed services in one own }); const standaloneStorage = yield* owner.rpc.createService({ service: "storage", - config: { databaseUrl: "postgresql://placeholder", filePath: `${root}/standalone-uploads` }, + config: { filePath: `${root}/standalone-uploads` }, endpoints: {}, }); expect(standaloneStorage.creation.config).toMatchObject({ @@ -256,7 +256,7 @@ it.live("forwards and rotates saved identity across composed services in one own const retainedIds = ids.filter((id) => id !== studioId); const second = yield* owner.rpc.supabaseComposition({ services: secondServices, - identity: identity("two", "[{}]"), + keys: stackKeys("two", "[{}]"), reuseIds: retainedIds, }); const secondCredentials = yield* state @@ -298,7 +298,7 @@ it.live("forwards and rotates saved identity across composed services in one own yield* owner.rpc.stopComposition(); const third = yield* owner.rpc.supabaseComposition({ services: servicesFor(customJwtSecret), - identity: identity("three", "[]"), + keys: stackKeys("three", "[]"), reuseIds: ids, }); const thirdCredentials = yield* state @@ -405,7 +405,7 @@ it.live( }); const rest = yield* owner.rpc.createService({ service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }); const shadow = yield* owner.rpc.createService({ @@ -558,7 +558,7 @@ it.live( }, { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }, { @@ -630,12 +630,12 @@ it.effect("validates Supabase composition recipes before creating instances", () services: [ { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: 41_001 } }, }, { service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: 41_002 } }, }, ], @@ -672,7 +672,7 @@ it.effect("validates Supabase composition recipes before creating instances", () { ...database, endpoints: {} }, { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }, ], @@ -769,7 +769,7 @@ it.effect("refuses to generate credentials for a stack whose saved instances con yield* state.save(withoutCredentials); const refused = yield* owner.rpc - .createService({ service: "auth", config: { databaseUrl: "postgresql://placeholder" } }) + .createService({ service: "auth", config: {} }) .pipe(Effect.flip); expect(refused.message).toBe( @@ -781,3 +781,45 @@ it.effect("refuses to generate credentials for a stack whose saved instances con }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.live("rejects a missing required input before starting or stopping the service", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-missing-input-" }); + const stack = initial("owner-missing-input"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + const unbound = yield* owner.rpc.createService({ + service: "rest", + config: {}, + endpoints: {}, + }); + + const startFailure = yield* owner.rpc.startService({ id: unbound.id }).pipe(Effect.flip); + + expect(startFailure.message).toContain("rest requires input databaseUrl"); + expect((yield* owner.rpc.status({ id: unbound.id })).lifecycle).toBe("stopped"); + + const provided = yield* owner.rpc.createService({ + service: "rest", + config: { databaseUrl: "postgresql://authenticator@127.0.0.1:1/postgres" }, + endpoints: {}, + }); + yield* owner.rpc.startService({ id: provided.id }); + const restartFailure = yield* owner.rpc + .restartService({ id: provided.id, config: { service: "rest", config: { maxRows: 5 } } }) + .pipe(Effect.flip); + + expect(restartFailure.message).toContain("rest requires input databaseUrl"); + const kept = yield* owner.rpc.status({ id: provided.id }); + expect(kept.lifecycle).toBe("running"); + expect(kept.config).toMatchObject({ + config: { databaseUrl: "postgresql://authenticator@127.0.0.1:1/postgres" }, + }); + yield* owner.rpc.stopService({ id: provided.id }); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index c413687f12..e23b037923 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -53,6 +53,7 @@ import { } from "./composition/Supabase.ts"; import { makeServiceRecipe, + requireInputs, ServiceCreation, serviceSchemas, type CatalogRecipe, @@ -245,7 +246,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { ? Effect.void : Effect.fail( new CredentialError({ - message: `Service ${id} must be stopped with wake disabled before identity changes`, + message: `Service ${id} must be stopped with wake disabled before stack credentials change`, }), ), ), @@ -255,12 +256,12 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { const resolveStackCredentials = Effect.fn("Owner.resolveStackCredentials")(function* ( overrides: Effect.Success>, - identity?: StackIdentityInput, + keys?: StackIdentityInput, ) { const credentials = yield* options.state.withLock( Effect.gen(function* () { const current = yield* readSaved; - const next = yield* nextCredentials(current, overrides, identity); + const next = yield* nextCredentials(current, overrides, keys); if (next === current.credentials) return next; if (current.credentials !== undefined) yield* requireStopped(current.composition); yield* options.state.save({ ...current, credentials: next }); @@ -379,12 +380,14 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { startAt: (revision, inputs, wake, guard) => Ref.get(creation).pipe( Effect.flatMap((current) => mergeInputs(current, inputs)), + Effect.flatMap(requireInputs), Effect.flatMap((candidate) => core.startAt(revision, candidate, wake, guard)), ), restart: (revision, inputs, candidate, guard) => Ref.get(creation).pipe( Effect.flatMap((current) => restartCreation(current, candidate)), Effect.flatMap((next) => mergeInputs(next, inputs)), + Effect.flatMap(requireInputs), Effect.flatMap((next) => core.restart(next, revision, guard)), ), bind: namespace.bind.pipe(Effect.mapError(serviceError("bind"))), @@ -445,10 +448,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { inputs: ReadonlyArray, compositionOptions: SupabaseCompositionOptions, ) { - yield* resolveStackCredentials( - yield* credentialOverrides(inputs), - compositionOptions.identity, - ); + yield* resolveStackCredentials(yield* credentialOverrides(inputs), compositionOptions.keys); const creations = yield* Effect.forEach(inputs, resolveCredentials); return yield* makeSupabaseComposition( { @@ -641,8 +641,8 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { ).pipe(rpcError("restoreSnapshot")), resetData: ({ id }) => databaseData(id, ({ resetDatabaseData }) => resetDatabaseData).pipe(rpcError("resetData")), - supabaseComposition: ({ services, reuseIds, identity }) => - definitionChange(compose(services, { reuseIds, identity })).pipe( + supabaseComposition: ({ services, reuseIds, keys, eager }) => + definitionChange(compose(services, { reuseIds, keys, eager })).pipe( rpcError("supabaseComposition"), ), configureComposition: (configuration) => diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 9eb9733de8..593c017579 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -135,7 +135,8 @@ export const OwnerRpc = RpcGroup.make( payload: { services: Schema.Array(ServiceCreationInput), reuseIds: Schema.optionalKey(Schema.Array(Schema.String)), - identity: Schema.optionalKey(StackIdentityInput), + keys: Schema.optionalKey(StackIdentityInput), + eager: Schema.optionalKey(Schema.Boolean), }, success: Schema.Array(Definition), error: StackError, diff --git a/packages/stack/src/composition/Supabase.native.integration.test.ts b/packages/stack/src/composition/Supabase.native.integration.test.ts index c208f185e1..fffc8cc9b5 100644 --- a/packages/stack/src/composition/Supabase.native.integration.test.ts +++ b/packages/stack/src/composition/Supabase.native.integration.test.ts @@ -103,7 +103,6 @@ it.live("removes a managed SMTP binding when Mail is excluded", () => const auth = { service: "auth" as const, config: { - databaseUrl: "postgresql://placeholder", jwtSecret: "exclude-mail-jwt-secret-with-32-chars", jwtExpiry: 3600, }, @@ -221,7 +220,6 @@ it.live( { service: "auth", config: { - databaseUrl: "postgresql://placeholder", jwtSecret, jwtExpiry: 3600, }, @@ -230,7 +228,6 @@ it.live( { service: "storage", config: { - databaseUrl: "postgresql://placeholder", filePath: storageRoot, jwtSecret, }, @@ -375,7 +372,7 @@ it.live( databaseCreation, { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }, ], @@ -402,7 +399,6 @@ it.live( { service: "auth", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: Redacted.value(databaseCreation.config.jwtSecret), jwtExpiry: 3600, }, @@ -457,21 +453,18 @@ it.live( }; const rest = { service: "rest" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }; const pgmeta = { service: "pgmeta" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }; const studio = { service: "studio" as const, config: { - pgmetaUrl: "http://placeholder", analyticsApiKey: "studio-reuse-analytics-key", - apiUrl: "http://placeholder", - publicApiUrl: "http://placeholder", jwtSecret: "studio-reuse-jwt-secret-with-32-chars", }, endpoints: { http: { port: FIXED_STUDIO_PORT } }, diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index e79c7de431..c49e9fcea4 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -1,10 +1,158 @@ -import { Data, Effect, Exit, Schema } from "effect"; +import { Data, Effect, Exit, Redacted, Schema } from "effect"; +import { postgresVersion } from "../Artifacts.ts"; import { causeMessage, type CompositionConfig } from "../Orchestrator.ts"; import type { Observation } from "../Rpc.ts"; -import { ServiceCreation } from "../services/Catalog.ts"; -import type { StackIdentityInput } from "../State.ts"; +import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; +import type { SavedStack, StackIdentityInput } from "../State.ts"; +import { credentialInputNames } from "../host/Credentials.ts"; import { apiRoute, endpointNames, endpointPort } from "../host/Endpoints.ts"; +const managedBindings: ReadonlyArray<{ + readonly sourceKind: ServiceCreation["service"]; + readonly sourceEndpoint: string; + readonly output: string; + readonly targetKind: ServiceCreation["service"]; + readonly input: string; +}> = [ + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "authenticatorUrl", + targetKind: "rest", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "authDatabaseUrl", + targetKind: "auth", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "storageDatabaseUrl", + targetKind: "storage", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "storage", + input: "vectorDatabaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "realtime", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "pgmeta", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "internalDatabaseUrl", + targetKind: "analytics", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "internalDatabaseUrl", + targetKind: "pooler", + input: "databaseUrl", + }, + { + sourceKind: "imgproxy", + sourceEndpoint: "http", + output: "url", + targetKind: "storage", + input: "imgproxyUrl", + }, + { + sourceKind: "pgmeta", + sourceEndpoint: "http", + output: "url", + targetKind: "studio", + input: "pgmetaUrl", + }, + { + sourceKind: "analytics", + sourceEndpoint: "http", + output: "url", + targetKind: "studio", + input: "analyticsUrl", + }, + { + sourceKind: "analytics", + sourceEndpoint: "http", + output: "url", + targetKind: "vector", + input: "analyticsUrl", + }, + { + sourceKind: "functions", + sourceEndpoint: "http", + output: "url", + targetKind: "studio", + input: "functionsUrl", + }, + { + sourceKind: "mail", + sourceEndpoint: "smtp", + output: "smtpUrl", + targetKind: "auth", + input: "smtpUrl", + }, +]; + +/** Inputs the composition derives, each from its shared API endpoint or a sibling member kind. */ +const derivedInputs: Partial< + Record>> +> = { + auth: { externalApiUrl: "api" }, + studio: { + apiUrl: "api", + publicApiUrl: "api", + analyticsApiKey: "analytics", + functionsRoot: "functions", + }, + functions: { apiUrl: "api", databaseUrl: "database" }, +}; + +/** Derived inputs a project may set itself; a plan compares one whenever the request sets it. */ +const overridableInputs: Partial>> = { + studio: ["publicApiUrl"], +}; + +/** Names every config input the package supplies to a composition member of this kind. */ +const managedInputs = (service: ServiceCreation["service"]): ReadonlySet => + new Set([ + ...managedBindings.filter(({ targetKind }) => targetKind === service).map(({ input }) => input), + ...Object.keys(derivedInputs[service] ?? {}), + ...credentialInputNames(service), + ]); + +/** Inputs that sibling member kinds supply to a member of this kind, with their source kind. */ +const siblingInputs = ( + service: ServiceCreation["service"], +): ReadonlyArray => [ + ...managedBindings + .filter(({ targetKind }) => targetKind === service) + .map(({ input, sourceKind }) => [input, sourceKind] as const), + ...Object.entries(derivedInputs[service] ?? {}).flatMap(([input, source]) => + source === "api" ? [] : [[input, source] as const], + ), +]; + export class SupabaseCompositionError extends Data.TaggedError("SupabaseCompositionError")<{ readonly message: string; readonly cause?: unknown; @@ -46,9 +194,154 @@ export interface SupabaseCompositionOperations { export interface SupabaseCompositionOptions { /** Reuses stopped instances; inputs declare desired bindings, not previous resolved creations. */ readonly reuseIds?: ReadonlyArray; - readonly identity?: StackIdentityInput; + readonly keys?: StackIdentityInput; + /** + * Starts every member with the composition. By default the database and members without an + * endpoint start eagerly, and other members start on their first connection. + */ + readonly eager?: boolean; } +/** How a saved instance differs from a requested creation once package-managed inputs are set aside. */ +export type CreationChange = + | { readonly change: "unchanged" } + | { readonly change: "changed"; readonly paths: ReadonlyArray } + | { + /** The instance cannot adopt the request: its endpoints, artifact or data version differ. */ + readonly change: "incompatible"; + readonly paths: ReadonlyArray; + }; + +/** A saved instance of a requested service kind, compared with that request. */ +export type PlannedInstance = CreationChange & { + readonly id: string; + readonly service: ServiceCreation["service"]; + /** Whether the instance belongs to the saved composition. */ + readonly member: boolean; +}; + +const differences = (left: unknown, right: unknown, path: string): ReadonlyArray => { + if (Redacted.isRedacted(left) || Redacted.isRedacted(right)) + return Redacted.isRedacted(left) && + Redacted.isRedacted(right) && + Redacted.value(left) === Redacted.value(right) + ? [] + : [path]; + if (Array.isArray(left) || Array.isArray(right)) + return Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => differences(value, right[index], path).length === 0) + ? [] + : [path]; + if (isRecord(left) && isRecord(right)) + return [...new Set([...Object.keys(left), ...Object.keys(right)])] + .toSorted() + .flatMap((key) => differences(left[key], right[key], path === "" ? key : `${path}.${key}`)); + return Object.is(left, right) ? [] : [path]; +}; + +const sharesApiEndpoint = (creation: ServiceCreationInput): boolean => + apiRoute(creation.service) !== undefined && endpointNames(creation).includes("http"); + +/** Fixed ports requested for the shared API endpoint; composition accepts at most one. */ +const fixedApiPorts = (creations: ReadonlyArray): ReadonlySet => + new Set( + creations + .filter(sharesApiEndpoint) + .map((creation) => endpointPort(creation, "http")) + .filter((port): port is number => port !== "auto"), + ); + +/** Endpoint intents with an automatic shared API port replaced by the fixed one. */ +const withSharedApiPort = (creation: ServiceCreationInput, sharedPort: number | undefined) => + sharedPort === undefined || + !sharesApiEndpoint(creation) || + endpointPort(creation, "http") !== "auto" + ? creation.endpoints + : { ...(isRecord(creation.endpoints) ? creation.endpoints : {}), http: { port: sharedPort } }; + +const comparable = ( + creation: ServiceCreationInput, + sharedPort: number | undefined, + compared: ReadonlySet, +) => { + const managed = managedInputs(creation.service); + const config: Record = Object.fromEntries( + Object.entries(creation.config).filter(([key]) => compared.has(key) || !managed.has(key)), + ); + if (creation.service === "database") config.version = postgresVersion(creation.config.version); + return { version: creation.version, endpoints: withSharedApiPort(creation, sharedPort), config }; +}; + +/** Compares a requested creation with a saved one, ignoring inputs the package supplies. */ +const compareCreation = ( + saved: ServiceCreation, + requested: ServiceCreationInput, + sharedPort: number | undefined, + memberKinds: ReadonlySet, +): CreationChange => { + const overridable = overridableInputs[requested.service] ?? []; + // Without its supplying member, the composition keeps the project's own value of an input. + const compared = new Set([ + ...Object.entries(requested.config) + .filter(([key, value]) => value !== undefined && overridable.includes(key)) + .map(([key]) => key), + ...siblingInputs(requested.service) + .filter(([, source]) => !memberKinds.has(source)) + .map(([input]) => input), + ]); + const paths = differences( + comparable(saved, undefined, compared), + comparable(requested, sharedPort, compared), + "", + ); + const incompatible = paths.filter( + (path) => + path === "version" || + path === "endpoints" || + path.startsWith("endpoints.") || + (saved.service === "database" && path === "config.version"), + ); + return incompatible.length > 0 + ? { change: "incompatible", paths: incompatible } + : paths.length > 0 + ? { change: "changed", paths } + : { change: "unchanged" }; +}; + +/** Compares each saved instance of a requested kind with its request, without changing state. */ +export const planSupabaseComposition = ( + saved: Pick, + requested: ReadonlyArray, +): ReadonlyArray => { + const members = new Set(saved.composition.members.map(({ id }) => id)); + const memberKinds = new Set( + saved.instances.filter(({ id }) => members.has(id)).map(({ creation }) => creation.service), + ); + const requestedKinds = new Set(requested.map(({ service }) => service)); + const ports = fixedApiPorts([ + ...requested, + ...saved.instances + .filter(({ id, creation }) => members.has(id) && requestedKinds.has(creation.service)) + .map(({ creation }) => creation), + ]); + const sharedPort = ports.size === 1 ? [...ports][0] : undefined; + return saved.instances.flatMap(({ id, creation }) => { + const request = requested.find(({ service }) => service === creation.service); + return request === undefined + ? [] + : [ + { + id, + service: creation.service, + member: members.has(id), + ...compareCreation(creation, request, sharedPort, memberKinds), + }, + ]; + }); +}; + const compositionError = (message: string, cause?: unknown) => new SupabaseCompositionError({ message, cause }); @@ -136,31 +429,20 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( return entries; }); - const fixedPorts = new Set( - [...decoded, ...reusedEntries.map(({ creation }) => creation)] - .filter( - (creation) => - apiRoute(creation.service) !== undefined && endpointNames(creation).includes("http"), - ) - .map((creation) => endpointPort(creation, "http")) - .filter((port): port is number => port !== "auto"), - ); + const fixedPorts = fixedApiPorts([ + ...decoded, + ...reusedEntries.map(({ creation }) => creation), + ]); if (fixedPorts.size > 1) return yield* compositionError("Shared HTTP endpoints must use one port"); const sharedPort = [...fixedPorts][0]; const normalized = yield* Effect.forEach(decoded, (creation) => { - if ( - sharedPort === undefined || - apiRoute(creation.service) === undefined || - !endpointNames(creation).includes("http") || - endpointPort(creation, "http") !== "auto" - ) - return Effect.succeed(creation); - const endpoints = isRecord(creation.endpoints) ? creation.endpoints : {}; - return Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - endpoints: { ...endpoints, http: { port: sharedPort } }, - }).pipe(Effect.mapError(compositionErrorFrom)); + const endpoints = withSharedApiPort(creation, sharedPort); + return endpoints === creation.endpoints + ? Effect.succeed(creation) + : Schema.decodeUnknownEffect(ServiceCreation)({ ...creation, endpoints }).pipe( + Effect.mapError(compositionErrorFrom), + ); }); const reusedByKind = new Map(reusedEntries.map((entry) => [entry.creation.service, entry])); @@ -175,112 +457,6 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( ) return yield* compositionError("API URL bindings require a configured HTTP endpoint"); - const managedBindings: ReadonlyArray<{ - readonly sourceKind: ServiceCreation["service"]; - readonly sourceEndpoint: string; - readonly output: string; - readonly targetKind: ServiceCreation["service"]; - readonly input: string; - }> = [ - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "authenticatorUrl", - targetKind: "rest", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "authDatabaseUrl", - targetKind: "auth", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "storageDatabaseUrl", - targetKind: "storage", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "databaseUrl", - targetKind: "storage", - input: "vectorDatabaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "databaseUrl", - targetKind: "realtime", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "databaseUrl", - targetKind: "pgmeta", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "internalDatabaseUrl", - targetKind: "analytics", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "internalDatabaseUrl", - targetKind: "pooler", - input: "databaseUrl", - }, - { - sourceKind: "imgproxy", - sourceEndpoint: "http", - output: "url", - targetKind: "storage", - input: "imgproxyUrl", - }, - { - sourceKind: "pgmeta", - sourceEndpoint: "http", - output: "url", - targetKind: "studio", - input: "pgmetaUrl", - }, - { - sourceKind: "analytics", - sourceEndpoint: "http", - output: "url", - targetKind: "studio", - input: "analyticsUrl", - }, - { - sourceKind: "analytics", - sourceEndpoint: "http", - output: "url", - targetKind: "vector", - input: "analyticsUrl", - }, - { - sourceKind: "functions", - sourceEndpoint: "http", - output: "url", - targetKind: "studio", - input: "functionsUrl", - }, - { - sourceKind: "mail", - sourceEndpoint: "smtp", - output: "smtpUrl", - targetKind: "auth", - input: "smtpUrl", - }, - ]; for (const { sourceKind, sourceEndpoint, targetKind } of managedBindings) { if (byKind.has(sourceKind) && byKind.has(targetKind)) { const source = byKind.get(sourceKind); @@ -428,7 +604,10 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( }), ); const members = configured.map(({ id, creation }) => { - const lazy = creation.service !== "database" && endpointNames(creation).length > 0; + const lazy = + options.eager !== true && + creation.service !== "database" && + endpointNames(creation).length > 0; return lazy ? creation.service === "functions" ? { id, activation: "lazy" as const } diff --git a/packages/stack/src/credentials.integration.test.ts b/packages/stack/src/credentials.integration.test.ts index e5679f6954..c66ce6dfc6 100644 --- a/packages/stack/src/credentials.integration.test.ts +++ b/packages/stack/src/credentials.integration.test.ts @@ -61,7 +61,7 @@ it.live("resolves composition credentials before creating services", () => const created = yield* stack.composition.supabase([ { service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }, { diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 51ba2f9f61..723bb24fc1 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -16,7 +16,14 @@ import { // oxlint-disable-next-line effecttsgo/node-builtin-import -- the test binds a dead owner's exact port. import * as Net from "node:net"; import { tmpdir } from "node:os"; -import { create, discover, open, type DatabaseInstance, type ServiceInstance } from "./effect.ts"; +import { + create, + discover, + find, + open, + type DatabaseInstance, + type ServiceInstance, +} from "./effect.ts"; import { initialization, postgres } from "./Commands.ts"; import { fileURLToPath } from "node:url"; import { launchHost } from "./HostProcess.ts"; @@ -28,6 +35,8 @@ import { destroyTestStack } from "../tests/stack-cleanup.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +// Below every OS ephemeral range, so another test's outbound socket cannot already hold it. +const FIXED_API_PORT = 24_393; const databaseOwnerMarker = Schema.fromJsonString( Schema.Struct({ stackId: Schema.String, instanceId: Schema.String }), ); @@ -805,3 +814,254 @@ it.live( }).pipe(Effect.scoped, Effect.provide(layer)), { timeout: 120_000 }, ); + +it.live("finds one saved stack by identity or id and fails on unreadable state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-find-" }); + const stateRoot = `${root}/state`; + expect(Option.isNone(yield* find({ stateRoot, projectRoot: root }))).toBe(true); + const stack = yield* create({ + projectRoot: root, + name: "feature", + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + + const byIdentity = Option.getOrUndefined( + yield* find({ stateRoot, projectRoot: root, name: "feature" }), + ); + expect(byIdentity?.definition.id).toBe(stack.id); + expect(byIdentity?.host).toBeUndefined(); + expect(Option.isNone(yield* find({ stateRoot, projectRoot: root }))).toBe(true); + const byId = yield* Effect.promise(() => PromiseApi.find({ stateRoot, id: stack.id })); + expect(byId?.definition.identity.stackName).toBe("feature"); + + yield* fs.writeFileString(`${stateRoot}/${stack.id}/state.json`, "{broken"); + const failure = yield* find({ stateRoot, projectRoot: root, name: "feature" }).pipe( + Effect.flip, + ); + expect(failure.operation).toBe("find"); + expect(failure.message).toContain(stack.id); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("plans requested creations against the saved composition and honours eager", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-" }); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const database = { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("plan-database-password"), + jwtSecret: Redacted.make("plan-database-jwt-secret-at-least-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + } as const; + const rest = { + service: "rest", + config: { maxRows: 100 }, + endpoints: { http: { port: FIXED_API_PORT } }, + } as const; + const auth = { + service: "auth", + config: {}, + endpoints: { http: { port: "auto" } }, + } as const; + const members = yield* stack.composition.supabase([database, rest, auth]); + const databaseId = members.find(({ service }) => service === "database")?.id; + const restId = members.find(({ service }) => service === "rest")?.id; + const authId = members.find(({ service }) => service === "auth")?.id; + expect((yield* stack.composition.describe).members).toEqual([ + { id: databaseId, activation: "eager" }, + { id: restId, activation: "lazy", idleMillis: 60_000 }, + { id: authId, activation: "lazy", idleMillis: 60_000 }, + ]); + + expect( + yield* stack.composition.plan([ + database, + { ...rest, config: { maxRows: 500 } }, + auth, + { service: "mail", config: {} }, + ]), + ).toEqual([ + { id: databaseId, service: "database", member: true, change: "unchanged" }, + { + id: restId, + service: "rest", + member: true, + change: "changed", + paths: ["config.maxRows"], + }, + { id: authId, service: "auth", member: true, change: "unchanged" }, + ]); + const client = yield* Effect.promise(() => + PromiseApi.open({ id: stack.id, stateRoot: `${root}/state`, cacheRoot: `${root}/cache` }), + ); + const promisePlan = yield* Effect.promise(() => + client.composition + .plan([{ ...rest, config: { maxRows: 100 } }]) + .finally(() => client.close()), + ); + expect(promisePlan).toEqual([ + { id: restId, service: "rest", member: true, change: "unchanged" }, + ]); + expect( + yield* stack.composition.plan([ + { ...database, config: { ...database.config, version: "15" } }, + { ...rest, endpoints: { http: { port: 54_999 } } }, + ]), + ).toEqual([ + { + id: databaseId, + service: "database", + member: true, + change: "incompatible", + paths: ["config.version"], + }, + { + id: restId, + service: "rest", + member: true, + change: "incompatible", + paths: ["endpoints.http.port"], + }, + ]); + + yield* stack.composition.supabase([database, rest], { + reuseIds: [databaseId, restId].filter((id) => id !== undefined), + eager: true, + }); + expect((yield* stack.composition.describe).members).toEqual([ + { id: databaseId, activation: "eager" }, + { id: restId, activation: "eager" }, + ]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("plans a Studio public API URL the project sets but not the one the stack derives", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-studio-" }); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const studio = { + service: "studio", + config: {}, + endpoints: { http: { port: "auto" } }, + } as const; + const members = yield* stack.composition.supabase([ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("plan-database-password"), + jwtSecret: Redacted.make("plan-database-jwt-secret-at-least-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + { service: "rest", config: {}, endpoints: { http: { port: FIXED_API_PORT } } }, + studio, + ]); + const studioId = members.find(({ service }) => service === "studio")?.id; + const planStudio = (config: { readonly publicApiUrl?: string }) => + stack.composition.plan([{ ...studio, config }]); + + expect(yield* planStudio({})).toEqual([ + { id: studioId, service: "studio", member: true, change: "unchanged" }, + ]); + expect(yield* planStudio({ publicApiUrl: "https://studio-api.example.test" })).toEqual([ + { + id: studioId, + service: "studio", + member: true, + change: "changed", + paths: ["config.publicApiUrl"], + }, + ]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("plans a project's own URL for an input whose supplying member is absent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-unbound-" }); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const external = "postgresql://postgres@external.example.test:5432/postgres"; + const moved = "postgresql://postgres@moved.example.test:5432/postgres"; + const rest = { + service: "rest", + config: { databaseUrl: external }, + endpoints: { http: { port: "auto" } }, + } as const; + const functions = { + service: "functions", + config: { functionsRoot: `${root}/functions`, databaseUrl: external }, + endpoints: { http: { port: "auto" } }, + } as const; + const members = yield* stack.composition.supabase([rest, functions]); + const restId = members.find(({ service }) => service === "rest")?.id; + const functionsId = members.find(({ service }) => service === "functions")?.id; + + expect(yield* stack.composition.plan([rest, functions])).toEqual([ + { id: restId, service: "rest", member: true, change: "unchanged" }, + { id: functionsId, service: "functions", member: true, change: "unchanged" }, + ]); + expect( + yield* stack.composition.plan([ + { ...rest, config: { databaseUrl: moved } }, + { ...functions, config: { ...functions.config, databaseUrl: moved } }, + ]), + ).toEqual([ + { + id: restId, + service: "rest", + member: true, + change: "changed", + paths: ["config.databaseUrl"], + }, + { + id: functionsId, + service: "functions", + member: true, + change: "changed", + paths: ["config.databaseUrl"], + }, + ]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index bad440e6d3..935733d3b9 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -30,8 +30,13 @@ import { shutdownHost, waitForOwnerExit, type HostAccess, + type HostEndpoint, } from "./HostProcess.ts"; -import type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; +import { + planSupabaseComposition, + type PlannedInstance, + type SupabaseCompositionOptions, +} from "./composition/Supabase.ts"; import { removeStackContainersCommand } from "./runtime/Container.ts"; import { volumeDataCleanupCommands } from "./storage/DockerDatabaseStorage.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; @@ -73,7 +78,13 @@ export { StackError } from "./Rpc.ts"; export type { ServiceCreation } from "./services/Catalog.ts"; export type ServiceCreationInput = CatalogServiceCreationInput; export type { CompositionConfig } from "./Orchestrator.ts"; -export type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; +export type { + CreationChange, + PlannedInstance, + SupabaseCompositionOptions, +} from "./composition/Supabase.ts"; +export { StackIdSchema as StackId } from "./identity/StackId.ts"; +export type { SavedStack } from "./State.ts"; export type { StackCredentials, StackIdentityInput }; export type { Observation } from "./Rpc.ts"; export type { @@ -233,6 +244,13 @@ export interface Stack { services: ReadonlyArray, options?: SupabaseCompositionOptions, ) => Effect.Effect, StackError>; + /** + * Compares the requested creations with every saved instance of the same kinds, ignoring + * inputs the composition supplies, without changing state or contacting the owner. + */ + readonly plan: ( + services: ReadonlyArray, + ) => Effect.Effect, StackError>; readonly configure: (config: Orchestrator.CompositionConfig) => Effect.Effect; readonly describe: Effect.Effect; readonly start: Effect.Effect, StackError>; @@ -840,9 +858,21 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( rpc.supabaseComposition({ services, ...(options?.reuseIds === undefined ? {} : { reuseIds: options.reuseIds }), - ...(options?.identity === undefined ? {} : { identity: options.identity }), + ...(options?.keys === undefined ? {} : { keys: options.keys }), + ...(options?.eager === undefined ? {} : { eager: options.eager }), }), ).pipe(Effect.map((definitions) => definitions.map(instance))), + plan: (services: ReadonlyArray) => + Effect.forEach(services, (service) => + Schema.decodeEffect(ServiceCreationInputSchema)(service), + ).pipe( + Effect.mapError((cause) => failure("plan", cause)), + Effect.flatMap((requested) => + savedDefinition.pipe( + Effect.map((current) => planSupabaseComposition(current, requested)), + ), + ), + ), configure: (config: Orchestrator.CompositionConfig) => call("configureComposition", (rpc) => rpc.configureComposition(config)), describe: savedDefinition.pipe(Effect.map((current) => current.composition)), @@ -949,3 +979,26 @@ export const discover = Effect.fn("Stack.discover")( }, Effect.mapError((cause) => failure("discover", cause)), ); + +/** Selects a saved stack by id, or by the identity a project root and stack name derive. */ +export type FindOptions = Pick & + ({ readonly id: string } | { readonly projectRoot: string; readonly name?: string }); + +/** A saved stack with the endpoint of the live owner holding its lease, if any. */ +export interface FoundStack { + readonly definition: SavedStack; + readonly host: HostEndpoint | undefined; +} + +/** Reads the one saved stack a selection names; unreadable state fails instead of being skipped. */ +export const find = Effect.fn("Stack.find")( + function* (options: FindOptions) { + const state = yield* stateFor(options.stateRoot); + const id = + "id" in options ? options.id : yield* deriveStackId(yield* resolveStackIdentity(options)); + const definition = yield* state.read(id); + if (definition === undefined) return Option.none(); + return Option.some({ definition, host: yield* observeHost(state, definition) }); + }, + Effect.mapError((cause) => failure("find", cause)), +); diff --git a/packages/stack/src/host/Credentials.ts b/packages/stack/src/host/Credentials.ts index e623fc53c4..3172202853 100644 --- a/packages/stack/src/host/Credentials.ts +++ b/packages/stack/src/host/Credentials.ts @@ -5,7 +5,7 @@ import { DEFAULT_POSTGRES_ROOT_KEY, } from "../Defaults.ts"; import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; -import { resolveStackIdentity } from "../services/ServiceConfig.ts"; +import { resolveStackKeys } from "../services/ServiceConfig.ts"; import type { SavedStack, StackCredentials, StackIdentityInput } from "../State.ts"; export class CredentialError extends Data.TaggedError("CredentialError")<{ @@ -82,7 +82,7 @@ export const withoutUnusedCredentials = (saved: SavedStack): SavedStack => { return withoutCredentials; }; -const identityChanged = (saved: StackCredentials, next: StackCredentials) => +const credentialsChanged = (saved: StackCredentials, next: StackCredentials) => next.jwtSecret !== saved.jwtSecret || next.publishableKey !== saved.publishableKey || next.secretKey !== saved.secretKey || @@ -101,7 +101,7 @@ const identityChanged = (saved: StackCredentials, next: StackCredentials) => export const nextCredentials = Effect.fn("Credentials.next")(function* ( current: Pick, overrides: Overrides, - identity: StackIdentityInput | undefined, + keys: StackIdentityInput | undefined, ) { const saved = current.credentials; if (saved === undefined) { @@ -114,7 +114,7 @@ export const nextCredentials = Effect.fn("Credentials.next")(function* ( jwtSecret, postgresRootKey: overrides.postgresRootKey ?? DEFAULT_POSTGRES_ROOT_KEY, databasePassword: overrides.databasePassword ?? DEFAULT_LOCAL_DATABASE_PASSWORD, - ...(yield* resolveStackIdentity(jwtSecret, identity, undefined)), + ...(yield* resolveStackKeys(jwtSecret, keys, undefined)), } satisfies StackCredentials; } const conflict = @@ -124,7 +124,7 @@ export const nextCredentials = Effect.fn("Credentials.next")(function* ( (overrides.databasePassword !== undefined && overrides.databasePassword !== saved.databasePassword && "databasePassword") || - (identity === undefined && + (keys === undefined && overrides.jwtSecret !== undefined && overrides.jwtSecret !== saved.jwtSecret && "jwtSecret"); @@ -133,71 +133,77 @@ export const nextCredentials = Effect.fn("Credentials.next")(function* ( message: `Credential override ${conflict} conflicts with the saved stack value`, }); const jwtSecret = - identity === undefined ? saved.jwtSecret : (overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET); + keys === undefined ? saved.jwtSecret : (overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET); const next: StackCredentials = { ...saved, jwtSecret, - ...(yield* resolveStackIdentity(jwtSecret, identity, saved)), + ...(yield* resolveStackKeys(jwtSecret, keys, saved)), }; - return identityChanged(saved, next) ? next : saved; + return credentialsChanged(saved, next) ? next : saved; }); -/** Completes a creation with the stack credentials it consumes. */ +type CredentialValue = { + [K in keyof StackCredentials]: StackCredentials[K] extends string ? K : never; +}[keyof StackCredentials]; + +/** Config inputs each service receives from the stack credential record, by input name. */ +const credentialInputs: { + readonly [K in ServiceCreation["service"]]: Readonly>; +} = { + database: { + databasePassword: "databasePassword", + jwtSecret: "jwtSecret", + rootKey: "postgresRootKey", + }, + rest: { jwtSecret: "jwtSecret", jwks: "jwks" }, + auth: { jwtSecret: "jwtSecret", gotrueJwtKeys: "gotrueJwtKeys" }, + realtime: { jwtSecret: "jwtSecret", jwks: "jwks" }, + storage: { + jwtSecret: "jwtSecret", + jwks: "jwks", + anonKey: "anonKey", + serviceRoleKey: "serviceRoleKey", + }, + functions: { + jwtSecret: "jwtSecret", + jwks: "jwks", + anonKey: "anonKey", + serviceRoleKey: "serviceRoleKey", + publishableKey: "publishableKey", + secretKey: "secretKey", + }, + studio: { + jwtSecret: "jwtSecret", + anonKey: "anonKey", + serviceRoleKey: "serviceRoleKey", + publishableKey: "publishableKey", + secretKey: "secretKey", + }, + pooler: { jwtSecret: "jwtSecret" }, + imgproxy: {}, + pgmeta: {}, + mail: {}, + analytics: {}, + vector: {}, +}; + +/** Names the config inputs the owner fills from the stack credential record. */ +export const credentialInputNames = (service: ServiceCreation["service"]): ReadonlyArray => + Object.keys(credentialInputs[service]); + +/** + * Completes a creation with the stack credentials it consumes. The JWT secret and database + * credentials always come from the record; other inputs keep an explicit value. + */ export const withCredentials = ( creation: ServiceCreationInput, credentials: StackCredentials, ): Effect.Effect => { const config: Record = { ...creation.config }; - switch (creation.service) { - case "database": - Object.assign(config, { - databasePassword: Redacted.make(credentials.databasePassword), - jwtSecret: Redacted.make(credentials.jwtSecret), - rootKey: Redacted.make(credentials.postgresRootKey), - }); - break; - case "auth": - Object.assign(config, { - jwtSecret: credentials.jwtSecret, - gotrueJwtKeys: creation.config.gotrueJwtKeys ?? credentials.gotrueJwtKeys, - }); - break; - case "rest": - case "realtime": - Object.assign(config, { - jwtSecret: credentials.jwtSecret, - jwks: creation.config.jwks ?? credentials.jwks, - }); - break; - case "storage": - Object.assign(config, { - jwtSecret: credentials.jwtSecret, - jwks: creation.config.jwks ?? credentials.jwks, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - }); - break; - case "functions": - Object.assign(config, { - jwtSecret: credentials.jwtSecret, - jwks: creation.config.jwks ?? credentials.jwks, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - publishableKey: creation.config.publishableKey ?? credentials.publishableKey, - secretKey: creation.config.secretKey ?? credentials.secretKey, - }); - break; - case "studio": - Object.assign(config, { - jwtSecret: credentials.jwtSecret, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - publishableKey: creation.config.publishableKey ?? credentials.publishableKey, - secretKey: creation.config.secretKey ?? credentials.secretKey, - }); - break; - default: - Object.assign(config, { jwtSecret: credentials.jwtSecret }); + for (const [input, source] of Object.entries(credentialInputs[creation.service])) { + const value = credentials[source]; + if (creation.service === "database") config[input] = Redacted.make(value); + else if (input === "jwtSecret" || config[input] === undefined) config[input] = value; } return Schema.decodeUnknownEffect(ServiceCreation)({ ...creation, config }); }; diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index 2009043c0d..03e491b57a 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -12,14 +12,16 @@ export class EndpointError extends Data.TaggedError("EndpointError")<{ const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -export const endpointNames = (creation: ServiceCreation): ReadonlyArray => { +type EndpointIntents = Pick; + +export const endpointNames = (creation: EndpointIntents): ReadonlyArray => { const configured: Readonly> = Object.fromEntries( Object.entries(creation.endpoints ?? {}), ); return allowedEndpointNames(creation.service).filter((name) => isRecord(configured[name])); }; -export const endpointPort = (creation: ServiceCreation, name: string): number | "auto" => { +export const endpointPort = (creation: EndpointIntents, name: string): number | "auto" => { const endpoints: unknown = creation.endpoints; if (!isRecord(endpoints)) return "auto"; const endpoint = endpoints[name]; diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 88308bc279..0ed668c651 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,5 +1,5 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect"; +import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect"; import * as StackEffect from "./effect.ts"; import { StackError } from "./Rpc.ts"; import { @@ -49,8 +49,15 @@ const decodeCreation = (creation: unknown) => export type { CompositionConfig } from "./Orchestrator.ts"; export type { Observation } from "./Rpc.ts"; export type { PgProveOptions } from "./effect.ts"; -export type { SupabaseCompositionOptions } from "./effect.ts"; -export type { CreateOptions, DestroyResult, OpenOptions, StackLocations } from "./effect.ts"; +export type { CreationChange, PlannedInstance, SupabaseCompositionOptions } from "./effect.ts"; +export type { + CreateOptions, + DestroyResult, + FindOptions, + FoundStack, + OpenOptions, + StackLocations, +} from "./effect.ts"; const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); type Runtime = ReturnType; @@ -295,6 +302,11 @@ const adapt = (handle: StackEffect.Stack, runtime: Runtime, scope: Scope.Closeab ), options, ), + plan: (services: ReadonlyArray, options?: CallOptions) => + run( + Effect.forEach(services, decodeCreation).pipe(Effect.flatMap(handle.composition.plan)), + options, + ), configure: (config: StackEffect.CompositionConfig, options?: CallOptions) => run(handle.composition.configure(config), options), describe: (options?: CallOptions) => run(handle.composition.describe, options), @@ -401,3 +413,10 @@ export const discover = ( }).pipe(Effect.provide(clientLayer)), ); }; +/** Reads one saved stack by id or by project identity; resolves `undefined` when none is saved. */ +export const find = ( + options: StackEffect.FindOptions, +): Promise => + Effect.runPromise( + StackEffect.find(options).pipe(Effect.map(Option.getOrUndefined), Effect.provide(clientLayer)), + ); diff --git a/packages/stack/src/internal/postgres-artifact.ts b/packages/stack/src/internal/artifacts.ts similarity index 50% rename from packages/stack/src/internal/postgres-artifact.ts rename to packages/stack/src/internal/artifacts.ts index 3a3121a83e..4e73b13dcf 100644 --- a/packages/stack/src/internal/postgres-artifact.ts +++ b/packages/stack/src/internal/artifacts.ts @@ -1,6 +1,7 @@ -/** Artifact preparation shared with the CLI's stack-independent PostgreSQL clients. */ +/** Artifact catalog and preparation shared with the CLI's stack-independent clients. */ export { ArtifactError, + artifactServiceKinds, postgresVersion, prepareNativeArtifact, resolveArtifact, diff --git a/packages/stack/src/internal/service-catalog.ts b/packages/stack/src/internal/service-catalog.ts deleted file mode 100644 index 3392a3c9f4..0000000000 --- a/packages/stack/src/internal/service-catalog.ts +++ /dev/null @@ -1 +0,0 @@ -export { artifactServiceKinds, postgresVersion, resolveArtifact } from "../Artifacts.ts"; diff --git a/packages/stack/src/services/Analytics.ts b/packages/stack/src/services/Analytics.ts index b25f211468..a8d25fd4b2 100644 --- a/packages/stack/src/services/Analytics.ts +++ b/packages/stack/src/services/Analytics.ts @@ -1,10 +1,10 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), backend: Schema.optionalKey(Schema.Literal("postgres")), apiKey: Schema.optionalKey(Schema.String), }); @@ -25,9 +25,14 @@ export const makeSpec = (): ProcessRecipeSpec => ({ env: (creation, endpoints, container) => Effect.gen(function* () { const http = endpoints.get("http"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "analytics", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { PORT: String(http.port), PHX_HTTP_PORT: String(http.port) }), @@ -43,7 +48,7 @@ export const makeSpec = (): ProcessRecipeSpec => ({ ...(creation.config.apiKey === undefined ? {} : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), - POSTGRES_BACKEND_URL: creation.config.databaseUrl, + POSTGRES_BACKEND_URL: databaseUrl, POSTGRES_BACKEND_SCHEMA: "_analytics", }; }), diff --git a/packages/stack/src/services/Auth.ts b/packages/stack/src/services/Auth.ts index e923a14403..c63474cd2c 100644 --- a/packages/stack/src/services/Auth.ts +++ b/packages/stack/src/services/Auth.ts @@ -2,7 +2,7 @@ import { Effect, Schema } from "effect"; import { ServiceError } from "../Service.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { DEFAULT_SIGNING_KEY } from "../Defaults.ts"; -import { localJwtSecret } from "./ServiceConfig.ts"; +import { localJwtSecret, requiredInput } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; import { Settings, settingsEnvironment } from "./AuthSettings.ts"; @@ -17,7 +17,7 @@ const Smtp = Schema.Struct({ }); export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), siteUrl: Schema.optionalKey(Schema.String), apiExternalUrl: Schema.optionalKey(Schema.String), externalApiUrl: Schema.optionalKey(Schema.String), @@ -63,81 +63,84 @@ const smtpEnvironment = Effect.fn("Auth.smtpEnvironment")((value: string) => }), ); +const defaultJwtKeys = JSON.stringify([DEFAULT_SIGNING_KEY]); + export const makeSpec = (): ProcessRecipeSpec => ({ service: "auth", executable: "bin/auth", ports: { http: 9999 }, healthPath: "/health", - env: (creation, endpoints, container) => { - const http = endpoints.get("http"); - const apiExternalUrl = creation.config.apiExternalUrl; - const authExternalUrl = - creation.config.authExternalUrl !== undefined && creation.config.authExternalUrl.length > 0 - ? creation.config.authExternalUrl - : apiExternalUrl !== undefined && apiExternalUrl.length > 0 - ? `${apiExternalUrl.replace(/\/+$/u, "")}/auth/v1` - : (creation.config.externalApiUrl ?? creation.config.siteUrl ?? "http://localhost:3000"); - const jwtIssuer = creation.config.settings?.jwtIssuer || authExternalUrl; - const base = { - GOTRUE_API_HOST: container ? "0.0.0.0" : "127.0.0.1", - GOTRUE_DB_DATABASE_URL: creation.config.databaseUrl, - DATABASE_URL: creation.config.databaseUrl, - GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: creation.config.siteUrl ?? "http://localhost:3000", - GOTRUE_JWT_SECRET: creation.config.jwtSecret ?? localJwtSecret, - GOTRUE_JWT_KEYS: creation.config.gotrueJwtKeys ?? JSON.stringify([DEFAULT_SIGNING_KEY]), - GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", - GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", - GOTRUE_JWT_AUD: "authenticated", - GOTRUE_JWT_ADMIN_ROLES: "service_role", - GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", - GOTRUE_MAILER_AUTOCONFIRM: "true", - GOTRUE_DISABLE_SIGNUP: String(creation.config.disableSignup ?? false), - GOTRUE_RATE_LIMIT_EMAIL_SENT: "360000", - ...(http === undefined ? {} : { GOTRUE_API_PORT: String(http.port) }), - API_EXTERNAL_URL: authExternalUrl, - GOTRUE_JWT_ISSUER: jwtIssuer, - GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - ...(creation.config.jwtExpiry === undefined - ? {} - : { GOTRUE_JWT_EXP: String(creation.config.jwtExpiry) }), - ...settingsEnvironment(creation.config.settings, jwtIssuer), - }; - const smtp = creation.config.smtp; - if (smtp !== undefined) { - return Effect.succeed({ + env: (creation, endpoints, container) => + Effect.gen(function* () { + const databaseUrl = yield* requiredInput("auth", "databaseUrl", creation.config.databaseUrl); + const http = endpoints.get("http"); + const apiExternalUrl = creation.config.apiExternalUrl; + const authExternalUrl = + creation.config.authExternalUrl !== undefined && creation.config.authExternalUrl.length > 0 + ? creation.config.authExternalUrl + : apiExternalUrl !== undefined && apiExternalUrl.length > 0 + ? `${apiExternalUrl.replace(/\/+$/u, "")}/auth/v1` + : (creation.config.externalApiUrl ?? + creation.config.siteUrl ?? + "http://localhost:3000"); + const jwtIssuer = creation.config.settings?.jwtIssuer || authExternalUrl; + const base = { + GOTRUE_API_HOST: container ? "0.0.0.0" : "127.0.0.1", + GOTRUE_DB_DATABASE_URL: databaseUrl, + DATABASE_URL: databaseUrl, + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_SITE_URL: creation.config.siteUrl ?? "http://localhost:3000", + GOTRUE_JWT_SECRET: creation.config.jwtSecret ?? localJwtSecret, + GOTRUE_JWT_KEYS: creation.config.gotrueJwtKeys ?? defaultJwtKeys, + GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", + GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + GOTRUE_MAILER_AUTOCONFIRM: "true", + GOTRUE_DISABLE_SIGNUP: String(creation.config.disableSignup ?? false), + GOTRUE_RATE_LIMIT_EMAIL_SENT: "360000", + ...(http === undefined ? {} : { GOTRUE_API_PORT: String(http.port) }), + API_EXTERNAL_URL: authExternalUrl, + GOTRUE_JWT_ISSUER: jwtIssuer, + GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + ...(creation.config.jwtExpiry === undefined + ? {} + : { GOTRUE_JWT_EXP: String(creation.config.jwtExpiry) }), + ...settingsEnvironment(creation.config.settings, jwtIssuer), + }; + const smtp = creation.config.smtp; + if (smtp !== undefined) { + return { + ...base, + GOTRUE_SMTP_HOST: smtp.host, + GOTRUE_SMTP_PORT: String(smtp.port), + GOTRUE_SMTP_USER: smtp.user, + GOTRUE_SMTP_PASS: smtp.pass, + GOTRUE_SMTP_ADMIN_EMAIL: smtp.adminEmail, + ...(creation.config.settings?.rateLimit?.email_sent === undefined + ? {} + : { + GOTRUE_RATE_LIMIT_EMAIL_SENT: String(creation.config.settings.rateLimit.email_sent), + }), + ...(smtp.senderName === undefined ? {} : { GOTRUE_SMTP_SENDER_NAME: smtp.senderName }), + }; + } + if (creation.config.smtpUrl === undefined) return base; + return { ...base, - GOTRUE_SMTP_HOST: smtp.host, - GOTRUE_SMTP_PORT: String(smtp.port), - GOTRUE_SMTP_USER: smtp.user, - GOTRUE_SMTP_PASS: smtp.pass, - GOTRUE_SMTP_ADMIN_EMAIL: smtp.adminEmail, - ...(creation.config.settings?.rateLimit?.email_sent === undefined + ...(yield* smtpEnvironment(creation.config.smtpUrl)), + ...(creation.config.smtpAdminEmail === undefined + ? {} + : { GOTRUE_SMTP_ADMIN_EMAIL: creation.config.smtpAdminEmail }), + ...(creation.config.smtpSenderName === undefined ? {} - : { - GOTRUE_RATE_LIMIT_EMAIL_SENT: String(creation.config.settings.rateLimit.email_sent), - }), - ...(smtp.senderName === undefined ? {} : { GOTRUE_SMTP_SENDER_NAME: smtp.senderName }), - }); - } - return creation.config.smtpUrl === undefined - ? Effect.succeed(base) - : smtpEnvironment(creation.config.smtpUrl).pipe( - Effect.map((value) => ({ - ...base, - ...value, - ...(creation.config.smtpAdminEmail === undefined - ? {} - : { GOTRUE_SMTP_ADMIN_EMAIL: creation.config.smtpAdminEmail }), - ...(creation.config.smtpSenderName === undefined - ? {} - : { GOTRUE_SMTP_SENDER_NAME: creation.config.smtpSenderName }), - })), - ); - }, + : { GOTRUE_SMTP_SENDER_NAME: creation.config.smtpSenderName }), + }; + }), args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), startupCommands: [initializationCommand], diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index b7336d3e4b..ce36b7944a 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -30,6 +30,7 @@ import { type ProcessRecipeResult, } from "./Recipe.ts"; import { makeProcessRecipe, type ProcessDependencies } from "./ProcessRecipe.ts"; +import { missingInput } from "./ServiceConfig.ts"; import { slimImageMirrors, type ServiceKind } from "../Artifacts.ts"; import type { ServiceInstanceContext } from "../Service.ts"; @@ -89,6 +90,31 @@ export const ServiceCreation = Schema.Union([ Pooler.Creation, ]); export type ServiceCreation = Schema.Schema.Type; + +/** Inputs a service cannot launch without; a composition binding or the caller supplies them. */ +const requiredInputs: { readonly [K in ServiceKind]?: ReadonlyArray } = { + rest: ["databaseUrl"], + auth: ["databaseUrl"], + realtime: ["databaseUrl"], + storage: ["databaseUrl"], + pgmeta: ["databaseUrl"], + analytics: ["databaseUrl"], + pooler: ["databaseUrl"], + vector: ["analyticsUrl"], +}; + +/** Rejects a creation that lacks a required input before any lifecycle change. */ +export const requireInputs = ( + creation: ServiceCreation, +): Effect.Effect => { + const config = new Map(Object.entries(creation.config)); + const missing = (requiredInputs[creation.service] ?? []).find( + (input) => config.get(input) === undefined, + ); + return missing === undefined + ? Effect.succeed(creation) + : Effect.fail(missingInput(creation.service, missing)); +}; const DatabaseCreationInput = serviceCreation( "database", Schema.Struct({ diff --git a/packages/stack/src/services/Pgmeta.ts b/packages/stack/src/services/Pgmeta.ts index 27060901bf..ecaefffb37 100644 --- a/packages/stack/src/services/Pgmeta.ts +++ b/packages/stack/src/services/Pgmeta.ts @@ -1,9 +1,9 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; -export const Config = Schema.Struct({ databaseUrl: Schema.String }); +export const Config = Schema.Struct({ databaseUrl: Schema.optionalKey(Schema.String) }); export interface Config extends Schema.Schema.Type {} export const Endpoints = Schema.Struct({ http: Schema.optionalKey(EndpointIntent) }); @@ -21,13 +21,18 @@ export const makeSpec = (): ProcessRecipeSpec => ({ env: (creation, endpoints, container) => Effect.gen(function* () { const http = endpoints.get("http"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "pgmeta", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, PG_META_HOST: container ? "0.0.0.0" : "127.0.0.1", ...(container ? {} : { PG_META_ADMIN_PORT: "0" }), ...(http === undefined ? {} : { PG_META_PORT: String(http.port) }), - PG_META_DB_URL: creation.config.databaseUrl, + PG_META_DB_URL: databaseUrl, PG_META_DB_HOST: db.host, PG_META_DB_PORT: db.port, PG_META_DB_NAME: db.database, diff --git a/packages/stack/src/services/Pooler.ts b/packages/stack/src/services/Pooler.ts index 092e1bd474..7e58fb7b11 100644 --- a/packages/stack/src/services/Pooler.ts +++ b/packages/stack/src/services/Pooler.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection, localJwtSecret } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput, localJwtSecret } from "./ServiceConfig.ts"; import { DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, DEFAULT_POOLER_VAULT_ENCRYPTION_KEY, @@ -8,7 +8,7 @@ import { import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), jwtSecret: Schema.optionalKey(Schema.String), tenant: Schema.optionalKey(Schema.String), defaultPoolSize: Schema.optionalKey(Schema.Finite), @@ -28,10 +28,11 @@ const environment: ProcessRecipeSpec["env"] = (creation, endpoints, co Effect.gen(function* () { const http = endpoints.get("http"); const sql = endpoints.get("sql"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput("pooler", "databaseUrl", creation.config.databaseUrl); + const db = yield* databaseConnection(databaseUrl); const mode = creation.config.poolMode ?? "transaction"; return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { PORT: String(http.port) }), ...(creation.config.tenant === undefined ? {} : { TENANT_ID: creation.config.tenant }), POSTGRES_HOST: db.host, diff --git a/packages/stack/src/services/Realtime.ts b/packages/stack/src/services/Realtime.ts index e29af7f9ff..fdea63c338 100644 --- a/packages/stack/src/services/Realtime.ts +++ b/packages/stack/src/services/Realtime.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection, localJwtSecret } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput, localJwtSecret } from "./ServiceConfig.ts"; import { DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, DEFAULT_REALTIME_DB_ENCRYPTION_KEY, @@ -8,7 +8,7 @@ import { import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), jwtSecret: Schema.optionalKey(Schema.String), jwks: Schema.optionalKey(Schema.String), dbEncryptionKey: Schema.optionalKey(Schema.String), @@ -40,14 +40,19 @@ export const makeSpec = (): ProcessRecipeSpec => ({ healthPath: "/healthcheck", env: (creation, endpoints, container) => Effect.gen(function* () { - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "realtime", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); const http = endpoints.get("http"); const rpc = endpoints.get("rpc"); const jwt = creation.config.jwtSecret ?? localJwtSecret; return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { PORT: String(http.port) }), - DB_URL: creation.config.databaseUrl, + DB_URL: databaseUrl, DB_HOST: db.host, DB_PORT: db.port, DB_USER: db.username ?? "supabase_admin", diff --git a/packages/stack/src/services/Rest.ts b/packages/stack/src/services/Rest.ts index 48935da554..bbd80ef641 100644 --- a/packages/stack/src/services/Rest.ts +++ b/packages/stack/src/services/Rest.ts @@ -1,9 +1,10 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { requiredInput } from "./ServiceConfig.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), externalApiUrl: Schema.optionalKey(Schema.String), schemas: Schema.optionalKey(Schema.String), extraSearchPath: Schema.optionalKey(Schema.String), @@ -26,25 +27,27 @@ export const makeSpec = (): ProcessRecipeSpec => ({ executable: "bin/postgrest", ports: { http: 3000 }, healthPath: "/", - env: (creation, endpoints) => { - const http = endpoints.get("http"); - const jwtSecret = creation.config.jwks ?? creation.config.jwtSecret; - return Effect.succeed({ - DATABASE_URL: creation.config.databaseUrl, - PGRST_DB_URI: creation.config.databaseUrl, - ...(http === undefined ? {} : { PGRST_SERVER_PORT: String(http.port) }), - PGRST_DB_SCHEMAS: creation.config.schemas ?? "public,graphql_public", - ...(creation.config.extraSearchPath === undefined - ? {} - : { PGRST_DB_EXTRA_SEARCH_PATH: creation.config.extraSearchPath }), - PGRST_DB_ANON_ROLE: creation.config.anonRole ?? "anon", - PGRST_DB_MAX_ROWS: String(creation.config.maxRows ?? 1000), - ...(jwtSecret === undefined ? {} : { PGRST_JWT_SECRET: jwtSecret }), - ...(creation.config.externalApiUrl === undefined - ? {} - : { PGRST_OPENAPI_SERVER_PROXY_URI: creation.config.externalApiUrl }), - }); - }, + env: (creation, endpoints) => + Effect.gen(function* () { + const databaseUrl = yield* requiredInput("rest", "databaseUrl", creation.config.databaseUrl); + const http = endpoints.get("http"); + const jwtSecret = creation.config.jwks ?? creation.config.jwtSecret; + return { + DATABASE_URL: databaseUrl, + PGRST_DB_URI: databaseUrl, + ...(http === undefined ? {} : { PGRST_SERVER_PORT: String(http.port) }), + PGRST_DB_SCHEMAS: creation.config.schemas ?? "public,graphql_public", + ...(creation.config.extraSearchPath === undefined + ? {} + : { PGRST_DB_EXTRA_SEARCH_PATH: creation.config.extraSearchPath }), + PGRST_DB_ANON_ROLE: creation.config.anonRole ?? "anon", + PGRST_DB_MAX_ROWS: String(creation.config.maxRows ?? 1000), + ...(jwtSecret === undefined ? {} : { PGRST_JWT_SECRET: jwtSecret }), + ...(creation.config.externalApiUrl === undefined + ? {} + : { PGRST_OPENAPI_SERVER_PROXY_URI: creation.config.externalApiUrl }), + }; + }), args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), startupCommands: [], diff --git a/packages/stack/src/services/ServiceConfig.ts b/packages/stack/src/services/ServiceConfig.ts index d9005ca0d4..f13d71036e 100644 --- a/packages/stack/src/services/ServiceConfig.ts +++ b/packages/stack/src/services/ServiceConfig.ts @@ -20,7 +20,7 @@ const serviceError = (operation: string, cause: unknown): ServiceError => const stringify = (value: unknown) => Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( - Effect.mapError((cause) => serviceError("identity", cause)), + Effect.mapError((cause) => serviceError("keys", cause)), ); export const localJwtSecret = DEFAULT_LOCAL_JWT_SECRET; @@ -43,10 +43,10 @@ const fixedJwt = (secret: string, role: "anon" | "service_role") => new SignJWT({ iss: "supabase-demo", role, exp: 1983812996 }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .sign(new TextEncoder().encode(secret)), - catch: (cause) => serviceError("identity", cause), + catch: (cause) => serviceError("keys", cause), }); -const defaultStackIdentity = (jwtSecret: string) => +const defaultStackKeys = (jwtSecret: string) => Effect.gen(function* () { const anonKey = yield* fixedJwt(jwtSecret, "anon"); const serviceRoleKey = yield* fixedJwt(jwtSecret, "service_role"); @@ -65,14 +65,14 @@ const defaultStackIdentity = (jwtSecret: string) => const JsonArray = Schema.Array(Schema.Unknown); const jsonArray = (value: string, field: string) => Schema.decodeEffect(Schema.fromJsonString(JsonArray))(value).pipe( - Effect.mapError((cause) => serviceError("identity", new Error(`${field}: ${cause.message}`))), + Effect.mapError((cause) => serviceError("keys", new Error(`${field}: ${cause.message}`))), ); -export const resolveStackIdentity = Effect.fn("ServiceConfig.resolveStackIdentity")( +export const resolveStackKeys = Effect.fn("ServiceConfig.resolveStackKeys")( (jwtSecret: string, input: StackIdentityInput | undefined, saved: StackCredentials | undefined) => Effect.gen(function* () { if (input === undefined && saved !== undefined) return saved; - const defaults = yield* defaultStackIdentity(jwtSecret); + const defaults = yield* defaultStackKeys(jwtSecret); const gotrueJwtKeys = input?.gotrueJwtKeys ?? defaults.gotrueJwtKeys; const publicSigningKeys = input?.publicSigningKeys ?? defaults.publicSigningKeys; const remoteJwks = input?.remoteJwks ?? "[]"; @@ -85,7 +85,7 @@ export const resolveStackIdentity = Effect.fn("ServiceConfig.resolveStackIdentit if (saved !== undefined) { const savedJwks = yield* Schema.decodeEffect( Schema.fromJsonString(Schema.Struct({ keys: Schema.Array(Schema.Unknown) })), - )(saved.jwks).pipe(Effect.mapError((cause) => serviceError("identity", cause))); + )(saved.jwks).pipe(Effect.mapError((cause) => serviceError("keys", cause))); const savedRemoteKeys = yield* jsonArray(saved.remoteJwks, "remoteJwks"); savedLocalKeys = savedJwks.keys.slice(savedRemoteKeys.length); } @@ -141,6 +141,21 @@ export const serviceJwt = Effect.fn("ServiceConfig.serviceJwt")( ).pipe(Effect.mapError((cause) => serviceError("launch", cause))), ); +/** A required input that is neither bound by a composition nor provided in config. */ +export const missingInput = (service: string, input: string): ServiceError => + new ServiceError({ + operation: "input", + message: `${service} requires input ${input}; bind it through a composition or provide it in config`, + }); + +/** Fails a launch whose required input is neither bound by a composition nor provided in config. */ +export const requiredInput = ( + service: string, + input: string, + value: string | undefined, +): Effect.Effect => + value === undefined ? Effect.fail(missingInput(service, input)) : Effect.succeed(value); + export const databaseConnection = Effect.fn("ServiceConfig.databaseConnection")( ( value: string, diff --git a/packages/stack/src/services/ServiceConfig.unit.test.ts b/packages/stack/src/services/ServiceConfig.unit.test.ts index 0093f94c70..b2be74d51d 100644 --- a/packages/stack/src/services/ServiceConfig.unit.test.ts +++ b/packages/stack/src/services/ServiceConfig.unit.test.ts @@ -8,7 +8,7 @@ import { DEFAULT_POSTGRES_ROOT_KEY, DEFAULT_SIGNING_KEY, } from "../Defaults.ts"; -import { resolveStackIdentity } from "./ServiceConfig.ts"; +import { resolveStackKeys } from "./ServiceConfig.ts"; const PublishedJwks = Schema.fromJsonString( Schema.Struct({ @@ -28,7 +28,7 @@ const PublishedJwks = Schema.fromJsonString( ); const savedDefaults = Effect.map( - resolveStackIdentity(DEFAULT_LOCAL_JWT_SECRET, undefined, undefined), + resolveStackKeys(DEFAULT_LOCAL_JWT_SECRET, undefined, undefined), (identity) => ({ ...identity, jwtSecret: DEFAULT_LOCAL_JWT_SECRET, @@ -49,7 +49,7 @@ it.effect("preserves the full saved identity when no identity input is supplied" jwks: '[{"kid":"saved-jwks"}]', }; - expect(yield* resolveStackIdentity(DEFAULT_LOCAL_JWT_SECRET, undefined, saved)).toEqual(saved); + expect(yield* resolveStackKeys(DEFAULT_LOCAL_JWT_SECRET, undefined, saved)).toEqual(saved); }), ); @@ -70,7 +70,7 @@ it.effect("removing key overrides uses new signing-key tokens and drops remote J remoteJwks: '[{"kid":"remote"}]', jwks: '{"keys":[{"kid":"remote"},{"kid":"public-signing-key"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: privateKeys, @@ -97,7 +97,7 @@ it.effect("removing key overrides uses new signing-key tokens and drops remote J it.effect("publishes a usable HMAC key for an empty signing-key file", () => Effect.gen(function* () { - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: "[]", publicSigningKeys: "[]" }, undefined, @@ -149,7 +149,7 @@ it.effect( .setProtectedHeader({ alg: "ES256", typ: "JWT", kid: DEFAULT_SIGNING_KEY.kid }) .sign(privateKey), ); - const configured = yield* resolveStackIdentity( + const configured = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))([ @@ -176,7 +176,7 @@ it.effect( yield* Effect.tryPromise(() => jwtVerify(configured.anonKey, publishedKey)); yield* Effect.tryPromise(() => jwtVerify(configured.serviceRoleKey, publishedKey)); - const reverted = yield* resolveStackIdentity( + const reverted = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, {}, { @@ -212,7 +212,7 @@ it.effect("retains generated asymmetric tokens when the signing source is unchan gotrueJwtKeys: privateKeys, jwks: '{"keys":[{"kid":"public-signing-key"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: privateKeys, @@ -240,7 +240,7 @@ it.effect("does not rotate local tokens when only remote JWKS changes", () => gotrueJwtKeys: '[{"kid":"local-private"}]', jwks: '{"keys":[{"kid":"local-public"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: saved.gotrueJwtKeys, @@ -268,7 +268,7 @@ it.effect("keeps generated tokens when signing key JSON formatting changes", () gotrueJwtKeys: '[{"kid":"local-private"}]', jwks: '{"keys":[{"kid":"local-public"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: '[ { "kid" : "local-private" } ]', diff --git a/packages/stack/src/services/Storage.ts b/packages/stack/src/services/Storage.ts index ba696c0496..bc97772f70 100644 --- a/packages/stack/src/services/Storage.ts +++ b/packages/stack/src/services/Storage.ts @@ -1,10 +1,10 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection, localJwtSecret, serviceJwt } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput, localJwtSecret, serviceJwt } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), filePath: Schema.String, jwtSecret: Schema.optionalKey(Schema.String), jwks: Schema.optionalKey(Schema.String), @@ -40,13 +40,18 @@ export const makeSpec = (): ProcessRecipeSpec => ({ env: (creation, endpoints, container) => Effect.gen(function* () { const http = endpoints.get("http"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "storage", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); const jwt = creation.config.jwtSecret ?? localJwtSecret; const anon = yield* serviceJwt("anon", jwt); const service = yield* serviceJwt("service_role", jwt); const filePath = container ? "/mnt" : creation.config.filePath; return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { STORAGE_PORT: String(http.port), PORT: String(http.port) }), ANON_KEY: creation.config.anonKey ?? anon, SERVICE_KEY: creation.config.serviceRoleKey ?? service, @@ -75,7 +80,7 @@ export const makeSpec = (): ProcessRecipeSpec => ({ : { VECTOR_BUCKET_PROVIDER: "pgvector", VECTOR_STORE_MIGRATIONS_ENABLED: "true", - VECTOR_DATABASE_URL: creation.config.vectorDatabaseUrl ?? creation.config.databaseUrl, + VECTOR_DATABASE_URL: creation.config.vectorDatabaseUrl ?? databaseUrl, }), ...(creation.config.vectorMaxBuckets === undefined ? {} diff --git a/packages/stack/src/services/Vector.ts b/packages/stack/src/services/Vector.ts index 083423b3c3..da59642e4b 100644 --- a/packages/stack/src/services/Vector.ts +++ b/packages/stack/src/services/Vector.ts @@ -1,6 +1,7 @@ import { Effect, type FileSystem, type Path, Schema } from "effect"; import { ServiceError } from "../Service.ts"; import { type CatalogOptions, EndpointIntent, serviceCreation } from "./Recipe.ts"; +import { requiredInput } from "./ServiceConfig.ts"; import { makeProcessRecipe, type ProcessDependencies, @@ -8,7 +9,7 @@ import { } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - analyticsUrl: Schema.String, + analyticsUrl: Schema.optionalKey(Schema.String), apiKey: Schema.optionalKey(Schema.String), /** Pipeline config without an `api` block; the recipe adds its own. */ configPath: Schema.optionalKey(Schema.String), @@ -82,18 +83,21 @@ const makeSpec = ( executable: "bin/vector", ports: { http: 9001 }, healthPath: "/health", - env: (creation, endpoints, container) => { - const http = endpoints.get("http"); - return Effect.succeed({ - ...(http === undefined - ? {} - : { VECTOR_API_ADDRESS: `${container ? "0.0.0.0" : "127.0.0.1"}:${http.port}` }), - LOGFLARE_URL: creation.config.analyticsUrl, - ...(creation.config.apiKey === undefined - ? {} - : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), - }); - }, + env: (creation, endpoints, container) => + requiredInput("vector", "analyticsUrl", creation.config.analyticsUrl).pipe( + Effect.map((analyticsUrl) => { + const http = endpoints.get("http"); + return { + ...(http === undefined + ? {} + : { VECTOR_API_ADDRESS: `${container ? "0.0.0.0" : "127.0.0.1"}:${http.port}` }), + LOGFLARE_URL: analyticsUrl, + ...(creation.config.apiKey === undefined + ? {} + : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), + }; + }), + ), args: (creation, _endpoints, context) => Effect.succeed( context.container diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index a6b1196419..f4b4785f12 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -139,23 +139,22 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => }, { service: "rest", - config: { databaseUrl: "postgresql://placeholder", jwtSecret: secret }, + config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") }, }, { service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret: secret }, + config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") }, }, { service: "realtime", - config: { databaseUrl: "postgresql://placeholder", jwtSecret: secret }, + config: { jwtSecret: secret }, endpoints: { http: endpoint("auto"), rpc: endpoint("auto") }, }, { service: "storage", config: { - databaseUrl: "postgresql://placeholder", filePath: storageRoot, jwtSecret: secret, }, @@ -174,7 +173,7 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => { service: "studio", config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") } }, { service: "pgmeta", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: endpoint("auto") }, }, { @@ -184,13 +183,12 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => }, { service: "analytics", - config: { databaseUrl: "postgresql://placeholder", backend: "postgres", apiKey: secret }, + config: { backend: "postgres", apiKey: secret }, endpoints: { http: endpoint("auto") }, }, { service: "vector", config: { - analyticsUrl: "http://placeholder", apiKey: secret, configPath: vectorConfigPath, }, @@ -199,7 +197,6 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => { service: "pooler", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: secret, tenant: "whole", poolMode: "transaction", @@ -207,7 +204,7 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => endpoints: { http: endpoint("auto"), sql: endpoint("auto") }, }, ], - { identity: { gotrueJwtKeys: "[]", publicSigningKeys: "[]" } }, + { keys: { gotrueJwtKeys: "[]", publicSigningKeys: "[]" } }, ); const logTails = yield* Ref.make>([]); yield* watchServiceLogs(created, logTails); From afe855aaedb51887d2d61ed3b0b69d182d1cb5d3 Mon Sep 17 00:00:00 2001 From: kanad Date: Mon, 28 Sep 2026 17:48:05 +0000 Subject: [PATCH 18/71] ci(release): upload release assets one at a time with retries (CLI-2455) (#6810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR `softprops/action-gh-release` uploads every release asset in parallel with no per-asset retry, so one dropped connection to `uploads.github.com` fails the whole publish job. Two beta releases failed this way in September: [beta.52](https://github.com/supabase/cli/actions/runs/35265023924) during a multi-hour degradation of the upload backend, and [beta.60](https://github.com/supabase/cli/actions/runs/35642579188) from a single dropped connection on an otherwise healthy day. ## What's introduced? In the `publish` job of `release-shared.yml`: - The action now creates an **empty draft** release; its `files:` input is gone. - A new `apps/cli/scripts/upload-release-assets.ts` defines the asset list once and exposes two subcommands, each run as its own workflow step: - **`upload`** runs `gh release upload --clobber` one asset at a time, up to three attempts each with a five-minute bound and a short backoff. `gh` already retries 5xx responses and dropped connections three times within a second; the outer loop covers longer stalls and the `--clobber` delete, which `gh` does not retry. - **`verify`** compares the release's assets in the `uploaded` state against the expected names and fails before `gh release edit --draft=false`, so a partial asset set can never be published. - Unit tests cover the asset list, retry policy, timeout handling, and verification through an injected runner. An integration test runs the real spawn path against a fake `gh` on `PATH`, including a hung upload that must be killed and retried. The script is plain Bun TypeScript to match its siblings in `apps/cli/scripts` (`publish.ts`, `sync-versions.ts`, the Homebrew and Scoop updaters), which the same job already invokes the same way. That is a deliberate choice against the repo's general Effect rule; the directory is excluded from Effect lint and has no Effect code today. `release-process.md` and ADR 0011 are updated to describe the new steps and the re-run window imposed by build-artifact cache eviction. The first five commits carried the same logic as inline bash; the last two replace it with the script. History is kept as is. ## Ref - closes: CLI-2455 - prior art for the same failure in other repos: [softprops/action-gh-release#536](https://github.com/softprops/action-gh-release/issues/536), [OpenwaterHealth/openmotion-bloodflow-app#555](https://github.com/OpenwaterHealth/openmotion-bloodflow-app/issues/555) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 --- .github/workflows/release-shared.yml | 36 +-- apps/cli/docs/release-process.md | 10 +- .../upload-release-assets.integration.test.ts | 190 +++++++++++++ apps/cli/scripts/upload-release-assets.ts | 251 ++++++++++++++++++ .../upload-release-assets.unit.test.ts | 222 ++++++++++++++++ ...1-cli-release-and-distribution-strategy.md | 17 +- 6 files changed, 691 insertions(+), 35 deletions(-) create mode 100644 apps/cli/scripts/upload-release-assets.integration.test.ts create mode 100644 apps/cli/scripts/upload-release-assets.ts create mode 100644 apps/cli/scripts/upload-release-assets.unit.test.ts diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 57b1d3e8a5..9fbb1e2479 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -375,6 +375,7 @@ jobs: cp "dist/supabase_${VERSION}_${triple}.tar.gz" "dist/supabase_${triple}.tar.gz" done + # Created empty; the next step uploads the assets so each one can be retried on its own. - name: Create draft GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: @@ -382,29 +383,18 @@ jobs: name: v${{ inputs.version }} draft: true prerelease: ${{ inputs.prerelease }} - files: | - dist/supabase_${{ inputs.version }}_darwin_arm64.tar.gz - dist/supabase_${{ inputs.version }}_darwin_amd64.tar.gz - dist/supabase_${{ inputs.version }}_linux_arm64.tar.gz - dist/supabase_${{ inputs.version }}_linux_amd64.tar.gz - dist/supabase_${{ inputs.version }}_linux_arm64.deb - dist/supabase_${{ inputs.version }}_linux_amd64.deb - dist/supabase_${{ inputs.version }}_linux_arm64.rpm - dist/supabase_${{ inputs.version }}_linux_amd64.rpm - dist/supabase_${{ inputs.version }}_linux_arm64.apk - dist/supabase_${{ inputs.version }}_linux_amd64.apk - dist/supabase_${{ inputs.version }}_windows_amd64.zip - dist/supabase_${{ inputs.version }}_windows_arm64.zip - dist/supabase_${{ inputs.version }}_windows_amd64.tar.gz - dist/supabase_${{ inputs.version }}_windows_arm64.tar.gz - dist/checksums.txt - dist/supabase_darwin_arm64.tar.gz - dist/supabase_darwin_amd64.tar.gz - dist/supabase_linux_arm64.tar.gz - dist/supabase_linux_amd64.tar.gz - dist/supabase_windows_arm64.tar.gz - dist/supabase_windows_amd64.tar.gz - install + + # One asset at a time with per-asset retries; see apps/cli/scripts/upload-release-assets.ts. + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts upload --version "${VERSION}" + + # Publishing makes the release immutable, so require every expected asset to be uploaded first. + - name: Verify release assets + env: + GH_TOKEN: ${{ github.token }} + run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" - name: Publish GitHub Release (immutable) env: diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 9e2433ca6c..3ed85be5f8 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -232,7 +232,7 @@ flowchart TD shared --> build["build
sync-versions, build.ts, nfpm
upload-artifact"] build --> smoke["smoke-test matrix
ubuntu-latest, macos-latest,
macos-15-intel, windows-latest"] smoke --> pub["publish
id-token: write (OIDC trusted publishing)
bun publish --provenance × 8 platform pkgs
then bun publish --provenance umbrella supabase"] - pub --> rel["softprops/action-gh-release
(draft) → gh release edit --draft=false"] + pub --> rel["softprops/action-gh-release (empty draft)
→ upload-release-assets.ts upload (gh release upload per asset)
→ upload-release-assets.ts verify → gh release edit --draft=false"] rel --> hb["publish-homebrew
App-token-authed clone of homebrew-tap
update-homebrew.ts --name "] rel --> sc["publish-scoop
App-token-authed clone of scoop-bucket
update-scoop.ts --name "] rel --> sucs["setup-cli-smoke
install via supabase/setup-cli
(GitHub Release download)"] @@ -300,8 +300,12 @@ The matrix does not yet include `windows-11-arm` (gate 6) or an Alpine musl runn 1. Re-runs `sync-versions.ts` (download-artifact restores file modes but not JSON mutations). 2. `[pnpm exec bun apps/cli/scripts/publish.ts --tag ](../scripts/publish.ts)` — publishes the eight platform packages in parallel via OIDC trusted publishing (`bun publish --provenance`, no `NPM_TOKEN`), then the umbrella package last so `optionalDependencies` resolve cleanly at install time. -3. `softprops/action-gh-release` creates a **draft** Release `v` on `supabase/cli` with all tar / zip / deb / rpm / apk + `checksums.txt`. -4. `gh release edit v --draft=false` finalises it (immutable from this point). +3. `softprops/action-gh-release` creates an **empty draft** Release `v` on `supabase/cli`. +4. `[upload-release-assets.ts upload](../scripts/upload-release-assets.ts)` uploads the archives, packages, `checksums.txt`, unversioned aliases, and `install` script **one asset at a time** with `gh release upload --clobber`, up to three attempts each with a five-minute timeout. `gh` itself retries 5xx responses and dropped connections three times within a second; the outer loop covers longer stalls and the `--clobber` delete, which `gh` does not retry. `uploads.github.com` fails single uploads often enough that this is routine: in September 2026 one beta release hit a multi-hour backend degradation and another lost one connection on a healthy backend. The asset list, retry policy, and timeout handling are covered by the unit and integration tests next to the script. +5. `upload-release-assets.ts verify` compares `gh release view --json assets` with the expected asset names. The job fails if any asset is missing or not `uploaded`, so a partial set is never published. +6. `gh release edit v --draft=false` finalises it (immutable from this point). + +A failed `publish` job can be re-run while the run's build-artifact cache exists (about a week); npm publish, the tag push, and the channel-note push skip work already done. Once the cache is evicted, the options are a `workflow_dispatch` on the tag, which rebuilds bytes that differ from npm and re-pushes the Homebrew/Scoop manifests, or deleting the draft. ### Post-publish: Homebrew + Scoop diff --git a/apps/cli/scripts/upload-release-assets.integration.test.ts b/apps/cli/scripts/upload-release-assets.integration.test.ts new file mode 100644 index 0000000000..1866370852 --- /dev/null +++ b/apps/cli/scripts/upload-release-assets.integration.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createSpawnRun, + KILL_GRACE_MS, + releaseAssets, + uploadAssets, + uploadedAssetNames, + type ReleaseIo, +} from "./upload-release-assets.ts"; + +const scriptPath = fileURLToPath(new URL("./upload-release-assets.ts", import.meta.url)); +const asset = { + path: "dist/supabase_1.0.0_darwin_arm64.tar.gz", + name: "supabase_1.0.0_darwin_arm64.tar.gz", +}; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +// A stand-in `gh` that logs every call, fails or hangs once per marker file, and serves +// `release view` from view.json. +const fakeGh = `#!/usr/bin/env bash +dir="$FAKE_GH_DIR" +echo "$*" >> "$dir/calls.log" +if [ "$1 $2" = "release view" ]; then + cat "$dir/view.json" + exit 0 +fi +name="$(basename "$4")" +if [ -f "$dir/fail-once-$name" ]; then + rm "$dir/fail-once-$name" + echo "HTTP 500: Error saving asset" >&2 + exit 1 +fi +if [ -f "$dir/hang-once-$name" ]; then + rm "$dir/hang-once-$name" + exec sleep 30 +fi +if [ -f "$dir/ignore-term-once-$name" ]; then + rm "$dir/ignore-term-once-$name" + trap '' TERM + sleep 30 & wait $! +fi +exit 0 +`; + +async function fakeGhOnPath() { + const directory = await mkdtemp(path.join(tmpdir(), "upload-release-assets-")); + temporaryDirectories.push(directory); + const bin = path.join(directory, "bin"); + await mkdir(bin); + await writeFile(path.join(bin, "gh"), fakeGh); + await chmod(path.join(bin, "gh"), 0o755); + const env = { ...process.env, FAKE_GH_DIR: directory, PATH: `${bin}:${process.env.PATH}` }; + const calls = async () => + (await readFile(path.join(directory, "calls.log"), "utf8")).trimEnd().split("\n"); + return { directory, env, calls }; +} + +function recordingIo(run: ReleaseIo["run"]): { io: ReleaseIo; logs: string[] } { + const logs: string[] = []; + return { + logs, + io: { + run, + fileExists: async () => true, + sleep: () => Promise.resolve(), + log: (line) => { + logs.push(line); + }, + }, + }; +} + +describe("upload-release-assets against a fake gh", () => { + test("kills an upload that outlives the timeout and succeeds on the retry", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + await writeFile(path.join(directory, `hang-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = Date.now(); + + await uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 500, + backoffMs: () => 0, + }); + + expect(Date.now() - startedAt).toBeLessThan(10_000); + expect(await calls()).toEqual([ + `release upload v1.0.0 ${asset.path} --clobber`, + `release upload v1.0.0 ${asset.path} --clobber`, + ]); + expect(logs).toEqual([ + `Upload of ${asset.name} failed on attempt 1 (timed out after 0.5s); retrying in 0s.`, + `Uploaded ${asset.name} (attempt 2).`, + ]); + }, 20_000); + + test("kills an upload that ignores SIGTERM once the grace period passes", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + await writeFile(path.join(directory, `ignore-term-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = Date.now(); + + await uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 500, + backoffMs: () => 0, + }); + + const elapsed = Date.now() - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(500 + KILL_GRACE_MS); + expect(elapsed).toBeLessThan(20_000); + expect(await calls()).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (timed out after 0.5s); retrying in 0s.`, + ); + }, 30_000); + + test("surfaces gh's stderr for a failed attempt and retries it", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + await writeFile(path.join(directory, `fail-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + + await uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + expect(await calls()).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0s.`, + ); + }); + + test("reads uploaded asset names from gh release view", async () => { + const { directory, env } = await fakeGhOnPath(); + await writeFile( + path.join(directory, "view.json"), + JSON.stringify({ + assets: [ + { name: "install", state: "uploaded" }, + { name: "checksums.txt", state: "starter" }, + ], + }), + ); + const { io } = recordingIo(createSpawnRun(env)); + + await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["install"]); + }); + + test("runs as a command and uploads all 22 assets from the working directory", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + const workdir = path.join(directory, "work"); + await mkdir(path.join(workdir, "dist"), { recursive: true }); + for (const asset of releaseAssets("1.0.0")) { + await writeFile(path.join(workdir, asset.path), asset.name); + } + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + + const child = Bun.spawn([bunExecutable, scriptPath, "upload", "--version", "1.0.0"], { + cwd: workdir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(await calls()).toHaveLength(22); + expect(stdout.trim().split("\n")).toHaveLength(22); + expect(stdout).toContain("Uploaded install (attempt 1)."); + }, 20_000); +}); diff --git a/apps/cli/scripts/upload-release-assets.ts b/apps/cli/scripts/upload-release-assets.ts new file mode 100644 index 0000000000..d0edef1a35 --- /dev/null +++ b/apps/cli/scripts/upload-release-assets.ts @@ -0,0 +1,251 @@ +import { parseArgs } from "node:util"; +import process from "node:process"; + +export interface ReleaseAsset { + path: string; + name: string; +} + +const TRIPLES = [ + "darwin_arm64", + "darwin_amd64", + "linux_arm64", + "linux_amd64", + "windows_arm64", + "windows_amd64", +] as const; + +/** Every file the publish job attaches to a GitHub Release, in upload order. */ +export function releaseAssets(version: string): ReleaseAsset[] { + const paths = [ + ...TRIPLES.map((triple) => `dist/supabase_${version}_${triple}.tar.gz`), + `dist/supabase_${version}_linux_arm64.deb`, + `dist/supabase_${version}_linux_amd64.deb`, + `dist/supabase_${version}_linux_arm64.rpm`, + `dist/supabase_${version}_linux_amd64.rpm`, + `dist/supabase_${version}_linux_arm64.apk`, + `dist/supabase_${version}_linux_amd64.apk`, + `dist/supabase_${version}_windows_amd64.zip`, + `dist/supabase_${version}_windows_arm64.zip`, + "dist/checksums.txt", + // setup-cli, the install script, and docs download releases/latest/download/. + ...TRIPLES.map((triple) => `dist/supabase_${triple}.tar.gz`), + "install", + ]; + return paths.map((path) => ({ path, name: path.slice(path.lastIndexOf("/") + 1) })); +} + +export interface RunResult { + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +export interface ReleaseIo { + run: (argv: string[], options: { timeoutMs: number }) => Promise; + fileExists: (path: string) => Promise; + sleep: (ms: number) => Promise; + log: (line: string) => void; +} + +export interface RetryPolicy { + maxAttempts: number; + timeoutMs: number; + backoffMs: (failedAttempts: number) => number; +} + +// gh retries a failed asset three times within a second on 5xx or a dropped connection. This +// outer policy covers the longer stalls uploads.github.com produces and the --clobber delete, +// which gh does not retry. Background: apps/cli/docs/release-process.md. +export const defaultRetryPolicy: RetryPolicy = { + maxAttempts: 3, + timeoutMs: 300_000, + backoffMs: (failedAttempts) => failedAttempts * 10_000, +}; + +function describeFailure(result: RunResult, timeoutMs: number): string { + if (result.timedOut) return `timed out after ${timeoutMs / 1000}s`; + const detail = result.stderr.trim().split("\n").at(-1) ?? ""; + return detail ? `exit ${result.exitCode}: ${detail}` : `exit ${result.exitCode}`; +} + +/** Uploads each asset in turn, retrying per asset, and throws on the first asset that never lands. */ +export async function uploadAssets( + tag: string, + assets: ReleaseAsset[], + io: ReleaseIo, + policy: RetryPolicy = defaultRetryPolicy, +): Promise { + for (const asset of assets) { + if (!(await io.fileExists(asset.path))) { + throw new Error(`Release asset ${asset.path} does not exist.`); + } + for (let attempt = 1; ; attempt++) { + const result = await io.run(["gh", "release", "upload", tag, asset.path, "--clobber"], { + timeoutMs: policy.timeoutMs, + }); + if (result.exitCode === 0) { + io.log(`Uploaded ${asset.name} (attempt ${attempt}).`); + break; + } + const failure = describeFailure(result, policy.timeoutMs); + if (attempt >= policy.maxAttempts) { + throw new Error( + `Upload of ${asset.name} to ${tag} failed after ${attempt} attempts (${failure}).`, + ); + } + const delayMs = policy.backoffMs(attempt); + io.log( + `Upload of ${asset.name} failed on attempt ${attempt} (${failure}); retrying in ${delayMs / 1000}s.`, + ); + await io.sleep(delayMs); + } + } +} + +interface ReleaseAssetView { + name: string; + state: string; +} + +/** Names of the release's assets that GitHub reports as fully uploaded. */ +export async function uploadedAssetNames(tag: string, io: ReleaseIo): Promise { + const result = await io.run(["gh", "release", "view", tag, "--json", "assets"], { + timeoutMs: 60_000, + }); + if (result.exitCode !== 0) { + throw new Error(`Could not read assets of ${tag} (${describeFailure(result, 60_000)}).`); + } + return parseAssetView(result.stdout, tag) + .filter((asset) => asset.state === "uploaded") + .map((asset) => asset.name); +} + +function isAssetView(value: unknown): value is ReleaseAssetView { + return ( + typeof value === "object" && + value !== null && + typeof (value as { name?: unknown }).name === "string" && + typeof (value as { state?: unknown }).state === "string" + ); +} + +function parseAssetView(stdout: string, tag: string): ReleaseAssetView[] { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`Could not read assets of ${tag}: gh returned invalid JSON.`); + } + const assets = (parsed as { assets?: unknown } | null)?.assets; + if (!Array.isArray(assets) || !assets.every(isAssetView)) { + throw new Error( + `Could not read assets of ${tag}: gh output has no assets array with name and state.`, + ); + } + return assets; +} + +/** Expected asset names that are absent from the uploaded set, sorted. */ +export function missingAssets(expected: string[], uploaded: string[]): string[] { + const present = new Set(uploaded); + return expected.filter((name) => !present.has(name)).sort(); +} + +const usage = `Usage: pnpm exec bun apps/cli/scripts/upload-release-assets.ts --version + + upload Upload every release asset to the draft release v, one at a time with retries. + verify Fail unless every expected asset is present on v and reported as uploaded.`; + +export async function main(argv: string[], io: ReleaseIo): Promise { + const { values, positionals } = parseArgs({ + args: argv, + options: { version: { type: "string" } }, + allowPositionals: true, + }); + const [command] = positionals; + if (!values.version || (command !== "upload" && command !== "verify")) { + io.log(usage); + return 2; + } + const tag = `v${values.version}`; + const assets = releaseAssets(values.version); + + if (command === "upload") { + await uploadAssets(tag, assets, io); + return 0; + } + + const missing = missingAssets( + assets.map((asset) => asset.name), + await uploadedAssetNames(tag, io), + ); + if (missing.length > 0) { + io.log(`::error::Release ${tag} is missing assets or has incomplete uploads:`); + for (const name of missing) io.log(` ${name}`); + return 1; + } + io.log(`All ${assets.length} expected assets are present on ${tag}.`); + return 0; +} + +/** + * Time a timed-out command gets to exit on SIGTERM before it is killed outright, and the bound on + * draining its pipes afterwards, since an orphaned descendant can hold them open. + */ +export const KILL_GRACE_MS = 5_000; + +function within(promise: Promise, ms: number, fallback: T): Promise { + let timer: ReturnType | undefined; + const expiry = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), ms); + }); + return Promise.race([promise, expiry]).finally(() => clearTimeout(timer)); +} + +/** Runs a command with the given environment and terminates it once the timeout elapses. */ +export function createSpawnRun( + env: Record = process.env, +): ReleaseIo["run"] { + return async (argv, { timeoutMs }) => { + const child = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }); + let timedOut = false; + let killTimer: ReturnType | undefined; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS); + }, timeoutMs); + // Start draining before the child exits so a chatty child never blocks on a full pipe. + const stdoutText = new Response(child.stdout).text(); + const stderrText = new Response(child.stderr).text(); + try { + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([ + within(stdoutText, KILL_GRACE_MS, ""), + within(stderrText, KILL_GRACE_MS, ""), + ]); + return { exitCode, stdout, stderr, timedOut }; + } finally { + clearTimeout(timer); + clearTimeout(killTimer); + } + }; +} + +export const processIo: ReleaseIo = { + run: createSpawnRun(), + fileExists: (path) => Bun.file(path).exists(), + sleep: (ms) => Bun.sleep(ms), + log: (line) => console.log(line), +}; + +if (import.meta.main) { + try { + process.exit(await main(process.argv.slice(2), processIo)); + } catch (cause) { + console.error(`::error::${cause instanceof Error ? cause.message : String(cause)}`); + process.exit(1); + } +} diff --git a/apps/cli/scripts/upload-release-assets.unit.test.ts b/apps/cli/scripts/upload-release-assets.unit.test.ts new file mode 100644 index 0000000000..8c4c99d88c --- /dev/null +++ b/apps/cli/scripts/upload-release-assets.unit.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "vitest"; +import { + main, + missingAssets, + releaseAssets, + uploadAssets, + uploadedAssetNames, + type ReleaseIo, + type RetryPolicy, + type RunResult, +} from "./upload-release-assets.ts"; + +const ok: RunResult = { exitCode: 0, stdout: "", stderr: "", timedOut: false }; +const failed: RunResult = { + exitCode: 1, + stdout: "", + stderr: "HTTP 500: Error saving asset\n", + timedOut: false, +}; +const timedOut: RunResult = { exitCode: null, stdout: "", stderr: "", timedOut: true }; + +const fastPolicy: RetryPolicy = { + maxAttempts: 3, + timeoutMs: 1_000, + backoffMs: (failedAttempts) => failedAttempts * 10, +}; + +/** Fake IO that replays scripted results per asset path and records what the script did. */ +function fakeIo( + script: Record = {}, + options: { missingFiles?: string[] } = {}, +) { + const state = { runs: [] as string[][], sleeps: [] as number[], logs: [] as string[] }; + const io: ReleaseIo = { + run: async (argv) => { + state.runs.push(argv); + const target = (argv[2] === "upload" ? argv[4] : argv[3]) ?? ""; + return script[target]?.shift() ?? ok; + }, + fileExists: async (path) => !options.missingFiles?.includes(path), + sleep: async (ms) => { + state.sleeps.push(ms); + }, + log: (line) => { + state.logs.push(line); + }, + }; + return { io, state }; +} + +describe("releaseAssets", () => { + test("lists the versioned archives, packages, checksums, unversioned aliases, and install script", () => { + const assets = releaseAssets("2.118.0-beta.60"); + const names = assets.map((asset) => asset.name); + + expect(assets).toHaveLength(22); + expect(names).toContain("supabase_2.118.0-beta.60_darwin_arm64.tar.gz"); + expect(names).toContain("supabase_2.118.0-beta.60_linux_amd64.deb"); + expect(names).toContain("checksums.txt"); + expect(names).toContain("supabase_darwin_arm64.tar.gz"); + expect(names).toContain("install"); + expect(new Set(names).size).toBe(22); + expect(assets.find((asset) => asset.name === "install")?.path).toBe("install"); + }); +}); + +describe("uploadAssets", () => { + const first = { + path: "dist/supabase_1.0.0_darwin_arm64.tar.gz", + name: "supabase_1.0.0_darwin_arm64.tar.gz", + }; + const second = { + path: "dist/supabase_1.0.0_linux_amd64.deb", + name: "supabase_1.0.0_linux_amd64.deb", + }; + const third = { path: "install", name: "install" }; + const assets = [first, second, third]; + + test("uploads every asset once with --clobber when nothing fails", async () => { + const { io, state } = fakeIo(); + + await uploadAssets("v1.0.0", assets, io, fastPolicy); + + expect(state.runs).toEqual( + assets.map((asset) => ["gh", "release", "upload", "v1.0.0", asset.path, "--clobber"]), + ); + expect(state.sleeps).toEqual([]); + expect(state.logs.filter((line) => line.startsWith("Uploaded "))).toHaveLength(3); + }); + + test("retries a failed asset with growing pauses and moves on once it lands", async () => { + const { io, state } = fakeIo({ [second.path]: [failed, failed] }); + + await uploadAssets("v1.0.0", assets, io, fastPolicy); + + const secondAttempts = state.runs.filter((argv) => argv[4] === second.path); + expect(secondAttempts).toHaveLength(3); + expect(state.sleeps).toEqual([10, 20]); + expect(state.logs).toContain( + `Upload of ${second.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0.01s.`, + ); + expect(state.logs).toContain(`Uploaded ${first.name} (attempt 1).`); + expect(state.logs).toContain(`Uploaded ${second.name} (attempt 3).`); + }); + + test("gives up on an asset after the last attempt and does not touch later assets", async () => { + const { io, state } = fakeIo({ [second.path]: [failed, failed, failed] }); + + await expect(uploadAssets("v1.0.0", assets, io, fastPolicy)).rejects.toThrow( + `Upload of ${second.name} to v1.0.0 failed after 3 attempts (exit 1: HTTP 500: Error saving asset).`, + ); + + expect(state.runs.filter((argv) => argv[4] === third.path)).toHaveLength(0); + expect(state.sleeps).toEqual([10, 20]); + }); + + test("treats a timed-out upload as a failed attempt", async () => { + const { io, state } = fakeIo({ [first.path]: [timedOut] }); + + await uploadAssets("v1.0.0", [first], io, fastPolicy); + + expect(state.runs).toHaveLength(2); + expect(state.logs[0]).toBe( + `Upload of ${first.name} failed on attempt 1 (timed out after 1s); retrying in 0.01s.`, + ); + }); + + test("fails before calling gh when an asset file is missing", async () => { + const { io, state } = fakeIo({}, { missingFiles: [first.path] }); + + await expect(uploadAssets("v1.0.0", [first], io, fastPolicy)).rejects.toThrow( + `Release asset ${first.path} does not exist.`, + ); + expect(state.runs).toEqual([]); + }); +}); + +describe("uploadedAssetNames", () => { + test("keeps only assets GitHub reports as uploaded", async () => { + const view = { + assets: [ + { name: "checksums.txt", state: "uploaded" }, + { name: "install", state: "starter" }, + ], + }; + const { io, state } = fakeIo({ "v1.0.0": [{ ...ok, stdout: JSON.stringify(view) }] }); + + await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["checksums.txt"]); + expect(state.runs).toEqual([["gh", "release", "view", "v1.0.0", "--json", "assets"]]); + }); + + test("fails when the release cannot be read", async () => { + const { io } = fakeIo({ "v1.0.0": [{ ...failed, stderr: "release not found\n" }] }); + + await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( + "Could not read assets of v1.0.0 (exit 1: release not found).", + ); + }); + + test("names the release when gh prints something other than JSON", async () => { + const { io } = fakeIo({ "v1.0.0": [{ ...ok, stdout: "gh: rate limited\n" }] }); + + await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( + "Could not read assets of v1.0.0: gh returned invalid JSON.", + ); + }); + + test("names the release when the JSON has no usable assets array", async () => { + const { io } = fakeIo({ + "v1.0.0": [{ ...ok, stdout: JSON.stringify({ assets: [{ name: "install" }] }) }], + }); + + await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( + "Could not read assets of v1.0.0: gh output has no assets array with name and state.", + ); + }); +}); + +describe("missingAssets", () => { + test("returns the expected names that are absent, sorted", () => { + expect(missingAssets(["b", "a", "c"], ["c"])).toEqual(["a", "b"]); + expect(missingAssets(["a"], ["a", "extra"])).toEqual([]); + }); +}); + +describe("main", () => { + const view = (names: string[]) => ({ + ...ok, + stdout: JSON.stringify({ assets: names.map((name) => ({ name, state: "uploaded" })) }), + }); + + test("prints usage for an unknown command or a missing version", async () => { + const { io, state } = fakeIo(); + + await expect(main(["publish", "--version", "1.0.0"], io)).resolves.toBe(2); + await expect(main(["upload"], io)).resolves.toBe(2); + expect(state.runs).toEqual([]); + expect(state.logs[0]).toContain("Usage:"); + }); + + test("verify succeeds when every expected asset is uploaded", async () => { + const names = releaseAssets("1.0.0").map((asset) => asset.name); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(0); + expect(state.logs).toEqual(["All 22 expected assets are present on v1.0.0."]); + }); + + test("verify fails and names each missing asset", async () => { + const names = releaseAssets("1.0.0") + .map((asset) => asset.name) + .filter((name) => name !== "checksums.txt" && name !== "install"); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(1); + expect(state.logs).toEqual([ + "::error::Release v1.0.0 is missing assets or has incomplete uploads:", + " checksums.txt", + " install", + ]); + }); +}); diff --git a/docs/adr/0011-cli-release-and-distribution-strategy.md b/docs/adr/0011-cli-release-and-distribution-strategy.md index d94538dd61..4cc4fed6c0 100644 --- a/docs/adr/0011-cli-release-and-distribution-strategy.md +++ b/docs/adr/0011-cli-release-and-distribution-strategy.md @@ -141,8 +141,8 @@ flowchart TD build["build job
sync-versions then build.ts then nfpm
upload artifact"] smoke["smoke-test matrix
ubuntu / macos-latest / macos-15-intel / windows-latest"] publish["publish job
bun publish × 8 platform pkgs
then bun publish umbrella"] - ghRelease["draft GitHub Release
tar/zip/deb/rpm/apk/checksums"] - finalize["gh release edit --draft=false"] + ghRelease["empty draft GitHub Release
gh release upload --clobber per asset
tar/zip/deb/rpm/apk/checksums"] + finalize["verify assets then
gh release edit --draft=false"] hbUpdate["update-homebrew.ts
Formula push"] scoopUpdate["update-scoop.ts
manifest push"] @@ -197,7 +197,7 @@ Production bucket: `supabase/scoop-bucket`. #### GitHub Releases -Draft + finalize by the shared workflow: +Draft, upload, verify, and finalize by the shared workflow: ```yaml # .github/workflows/release-shared.yml (publish job, excerpt) @@ -206,14 +206,13 @@ Draft + finalize by the shared workflow: tag_name: v${{ inputs.version }} draft: true prerelease: ${{ inputs.prerelease }} - files: | - dist/supabase_…_darwin_arm64.tar.gz - dist/supabase_…_linux_amd64.deb - … - dist/checksums.txt -- run: gh release edit v${{ inputs.version }} --draft=false +- run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts upload --version "${VERSION}" +- run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" +- run: gh release edit v${VERSION} --draft=false ``` +Assets go through [`upload-release-assets.ts`](../../apps/cli/scripts/upload-release-assets.ts), which calls `gh release upload` one asset at a time with a retry and then verifies the asset set, instead of the action's `files:` input: `uploads.github.com` fails single uploads often enough that one per release is routine, and the action uploads everything in parallel with no retry. Details in [release-process.md](../../apps/cli/docs/release-process.md). + Archive layout (per-platform): ```text From b55d91959c25b54fefb755f051433e03ce4cb342 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:28:52 +0000 Subject: [PATCH 19/71] refactor(cli): cover all `commands` with effect lint (CLI-2408) (#6849) ## TL;DR collapses the effect lint allow list to one `commands/**` entry now that every command family is covered ## ref - closes: CLI-2408 --- .oxlintrc.effect.json | 42 +----------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 0b7056e787..845e2382a4 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -7,53 +7,13 @@ "ignorePatterns": [ "**", "!packages/stack/**", - "!apps/cli/src/commands/backups/**", - "!apps/cli/src/commands/bootstrap/**", - "!apps/cli/src/commands/branches/**", - "!apps/cli/src/commands/completion/**", - "!apps/cli/src/commands/config/**", - "!apps/cli/src/commands/db/**", - "!apps/cli/src/commands/domains/**", - "!apps/cli/src/commands/encryption/**", - "!apps/cli/src/commands/experimental/**", - "!apps/cli/src/commands/feedback/**", - "!apps/cli/src/commands/functions/**", - "!apps/cli/src/commands/gen/**", - "!apps/cli/src/commands/init/**", - "!apps/cli/src/commands/inspect/**", - "!apps/cli/src/commands/issue/**", - "!apps/cli/src/commands/link/**", - "!apps/cli/src/commands/login/**", - "!apps/cli/src/commands/logout/**", - "!apps/cli/src/commands/migration/**", - "!apps/cli/src/commands/network-bans/**", - "!apps/cli/src/commands/network-restrictions/**", - "!apps/cli/src/commands/notebooks/**", - "!apps/cli/src/commands/orgs/**", - "!apps/cli/src/commands/postgres-config/**", - "!apps/cli/src/commands/projects/**", - "!apps/cli/src/commands/pull/**", - "!apps/cli/src/commands/secrets/**", - "!apps/cli/src/commands/seed/**", - "!apps/cli/src/commands/services/**", - "!apps/cli/src/commands/snippets/**", - "!apps/cli/src/commands/ssl-enforcement/**", - "!apps/cli/src/commands/sso/**", - "!apps/cli/src/commands/status/**", - "!apps/cli/src/commands/stop/**", - "!apps/cli/src/commands/storage/**", - "!apps/cli/src/commands/telemetry/**", - "!apps/cli/src/commands/test/**", - "!apps/cli/src/commands/unlink/**", - "!apps/cli/src/commands/vanity-subdomains/**", - "!apps/cli/src/commands/whoami/**", + "!apps/cli/src/commands/**", "!apps/cli/src/shared/compute/**", "!apps/cli/src/shared/functions/functions-docker.ts", "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", "!apps/cli/src/shared/runtime/file-watcher.service.ts", - "!apps/cli/src/commands/start/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", From a8830191962ac9de72accae701880b3bd537e012 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 29 Sep 2026 05:30:39 +0000 Subject: [PATCH 20/71] fix(stack): connect Studio to the database and restore its settings (#6865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** With the experimental stack (`[experimental].stack = true`), every Studio page backed by pg-meta failed. That includes the Table Editor, Auth users, Query Performance, Integrations, and lints. Studio was never told where Postgres is, so it defaulted to host `db`. Studio now gets the database connection from the stack. This PR also restores the Studio settings that the Compose `start` already provides. ### Before ```mermaid flowchart LR B[Browser] --> S[Studio] S -->|"connection string
host = db (default)"| M[pg-meta] M -->|"ENOTFOUND db"| E["500: Zod formattedError in the UI"] ``` ### After ```mermaid flowchart LR B[Browser] --> S[Studio] S -->|"connection string
POSTGRES_HOST/PORT from the database binding"| M[pg-meta] M --> D[(Stack Postgres)] ``` ### Why Studio builds the encrypted connection string it sends to pg-meta from `POSTGRES_HOST` / `POSTGRES_PORT` / `POSTGRES_DB` / `POSTGRES_PASSWORD`, and defaults the host to `db`. The stack set none of these, so pg-meta logged `getaddrinfo ENOTFOUND db`. The UI then surfaced a Zod parse error: `"path": ["formattedError"], "message": "Invalid input: expected string, received undefined"`. The Connect dialog also reported port 5432 instead of the stack's database port. ### What changed - The composition binds the database `sql` endpoint to Studio (the same URL pg-meta receives). Studio derives `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_PASSWORD`, and `POSTGRES_USER_READ_WRITE=postgres` from it. - Studio also receives: - `AUTH_JWT_SECRET` - `PGRST_DB_SCHEMAS` / `PGRST_DB_EXTRA_SEARCH_PATH` / `PGRST_DB_MAX_ROWS` from `[api]` - `NEXT_ANALYTICS_BACKEND_PROVIDER` - `CURRENT_CLI_VERSION` - `SNIPPETS_MANAGEMENT_FOLDER`, backed by a read-write mount of `supabase/snippets`. `stack start` creates that directory when Studio is selected, so saved SQL snippets persist in the project. - S3 protocol keys are not forwarded: the stack's Storage does not configure S3 credentials yet. ## Linked issue None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/src/command-internal/stack-config.ts | 6 +++ .../stack/stack-config.integration.test.ts | 21 ++++++++ .../experimental/stack/start/SIDE_EFFECTS.md | 4 +- .../experimental/stack/start/start.handler.ts | 13 +++++ .../stack/start/start.integration.test.ts | 2 + packages/stack/src/composition/Supabase.ts | 19 +++++-- .../src/services/Realtime.integration.test.ts | 8 +++ packages/stack/src/services/Studio.ts | 53 +++++++++++++++++-- .../stack/src/services/Studio.unit.test.ts | 39 ++++++++++++++ 9 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 packages/stack/src/services/Studio.unit.test.ts diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index e33ad30cc3..c17ef8e208 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -8,6 +8,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { loadLocalProjectContext, type LocalProjectContext } from "./local-project-context.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { CLI_VERSION } from "../shared/cli/version.ts"; import { resolveAuthConfig } from "./stack-auth-config.ts"; import { parseGoDuration } from "./go-duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; @@ -1310,6 +1311,11 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "studio" as const, config: { + snippetsRoot: `${projectRoot}/supabase/snippets`, + apiSchemas: validatedConfig.api.schemas.join(","), + apiExtraSearchPath: validatedConfig.api.extra_search_path.join(","), + apiMaxRows: validatedConfig.api.max_rows, + cliVersion: CLI_VERSION, ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), ...(validatedConfig.studio.openai_api_key === undefined ? {} diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 990121d197..ccbf300274 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -5,6 +5,7 @@ import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; import { importJWK, jwtVerify } from "jose"; import { ServiceCreationInput } from "../../../../../../packages/stack/src/services/Catalog.ts"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; +import { CLI_VERSION } from "../../../shared/cli/version.ts"; import { renderCliConfigTemplate } from "../../../shared/init/project-init.templates.ts"; import { loadStackConfig } from "../../../command-internal/stack-config.ts"; @@ -123,6 +124,26 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); + it.live("hands Studio the API settings it mirrors and its snippets folder", () => + Effect.gen(function* () { + const root = yield* project(`project_id = "stack-config-studio" +[api] +schemas = ["public", "storage"] +extra_search_path = ["public", "extensions"] +max_rows = 250 +`); + const config = yield* load(root); + const studio = byService(yield* config.creations("stack-studio")).get("studio"); + expect(studio?.service === "studio" ? studio.config : undefined).toMatchObject({ + snippetsRoot: `${root}/supabase/snippets`, + apiSchemas: "public,storage", + apiExtraSearchPath: "public,extensions", + apiMaxRows: 250, + cliVersion: CLI_VERSION, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("leaves stack-opt-in init listeners automatic except disabled pooler", () => Effect.gen(function* () { const root = yield* project(renderCliConfigTemplate("stack-config-init", false, true)); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 44364dd565..da985e7a39 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -24,7 +24,9 @@ S3 protocol/vector controls, and configured Vector ports are forwarded to their Encrypted JWT secrets are decrypted before shared credentials are derived. `db.health_timeout` controls database readiness; package JWT and PostgreSQL root-key defaults apply when omitted, and the effective root key is supplied through a stack-owned key file. -Studio receives the Functions management directory/URL and Analytics credentials when present. +Studio receives the database connection, the Functions management directory/URL, and Analytics +credentials when present. Starting with Studio creates `supabase/snippets/`, where Studio saves SQL +snippets. Email template `content_path` values and third-party identity providers remain unsupported: they require template serving and shared external JWKS verification respectively. diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 353d1bee74..5a6053a0da 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -410,6 +410,19 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); + if (requested.some(({ service }) => service === "studio")) + yield* fs + .makeDirectory(path.join(target.projectRoot, "supabase", "snippets"), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new StackCommandStartError({ + reason: "invalid-config", + message: `Unable to create the Studio snippets directory: ${cause.message}`, + cause, + }), + ), + ); const initialComposition = composition.members.length === 0; const serviceKindsChanged = !sameKinds(currentInstances, requested); const planned = yield* stack.composition.plan(requested).pipe(Effect.mapError(stackError)); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index c1eb09ca72..e07978b65a 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -519,6 +519,7 @@ describe("experimental stack start", () => { }), ); expect(fixture.members.map(({ service }) => service)).toEqual(["database"]); + expect(yield* fs.exists(`${root}/supabase/snippets`)).toBe(false); expect(fixture.composed).toBe(1); const repeatedOutput = mockOutput(); yield* stackStart(flags(excluded)).pipe( @@ -536,6 +537,7 @@ describe("experimental stack start", () => { yield* fixture.stack.composition.stop; yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); expect(fixture.members.some(({ service }) => service === "rest")).toBe(true); + expect(yield* fs.exists(`${root}/supabase/snippets`)).toBe(true); expect(fixture.composed).toBe(2); yield* fixture.stack.composition.stop; yield* stackStart(flags(["studio"])).pipe(Effect.provide(layers(root, fixture))); diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index c49e9fcea4..c5f5446b48 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -56,6 +56,13 @@ const managedBindings: ReadonlyArray<{ targetKind: "pgmeta", input: "databaseUrl", }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "studio", + input: "databaseUrl", + }, { sourceKind: "database", sourceEndpoint: "sql", @@ -123,6 +130,7 @@ const derivedInputs: Partial< apiUrl: "api", publicApiUrl: "api", analyticsApiKey: "analytics", + analyticsBackend: "analytics", functionsRoot: "functions", }, functions: { apiUrl: "api", databaseUrl: "database" }, @@ -581,11 +589,12 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( const values = { ...configInputs.get(entry.id), ...extra }; if (entry.creation.service === "studio") { const analytics = entriesByKind.get("analytics"); - if ( - analytics?.creation.service === "analytics" && - analytics.creation.config.apiKey !== undefined - ) - values.analyticsApiKey = analytics.creation.config.apiKey; + if (analytics?.creation.service === "analytics") { + if (analytics.creation.config.apiKey !== undefined) + values.analyticsApiKey = analytics.creation.config.apiKey; + if (analytics.creation.config.backend !== undefined) + values.analyticsBackend = analytics.creation.config.backend; + } const functions = entriesByKind.get("functions"); if (functions?.creation.service === "functions") values.functionsRoot = functions.creation.config.functionsRoot; diff --git a/packages/stack/src/services/Realtime.integration.test.ts b/packages/stack/src/services/Realtime.integration.test.ts index d6991a73a0..3d495a5ef9 100644 --- a/packages/stack/src/services/Realtime.integration.test.ts +++ b/packages/stack/src/services/Realtime.integration.test.ts @@ -90,6 +90,7 @@ describe("service catalog", () => { { service: "studio", config: { + databaseUrl, pgmetaUrl: `http://${pgmetaRelay.host}:${pgmetaRelay.port}`, analyticsApiKey: "catalog-analytics-key", apiUrl: "http://localhost:8000", @@ -112,6 +113,13 @@ describe("service catalog", () => { ), ); expect(profileResponse.status).toBe(200); + const queryResponse = yield* client.execute( + HttpClientRequest.post( + `http://${studioEndpoint.host}:${studioEndpoint.port}/api/platform/pg-meta/default/query`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ query: "select current_user" })), + ); + expect(queryResponse.status).toBe(200); + expect(yield* queryResponse.text).toContain('"current_user":"postgres"'); yield* studio.stop; yield* pgmeta.stop; diff --git a/packages/stack/src/services/Studio.ts b/packages/stack/src/services/Studio.ts index f3818fb58b..133e1f24ea 100644 --- a/packages/stack/src/services/Studio.ts +++ b/packages/stack/src/services/Studio.ts @@ -1,22 +1,31 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { serviceJwt } from "./ServiceConfig.ts"; +import { databaseConnection, serviceJwt } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +const CONTAINER_SNIPPETS_ROOT = "/__supabase_snippets"; + export const Config = Schema.Struct({ + databaseUrl: Schema.optionalKey(Schema.String), functionsRoot: Schema.optionalKey(Schema.String), + snippetsRoot: Schema.optionalKey(Schema.String), pgmetaUrl: Schema.optionalKey(Schema.String), analyticsUrl: Schema.optionalKey(Schema.String), analyticsApiKey: Schema.optionalKey(Schema.String), + analyticsBackend: Schema.optionalKey(Schema.Literal("postgres")), functionsUrl: Schema.optionalKey(Schema.String), apiUrl: Schema.optionalKey(Schema.String), publicApiUrl: Schema.optionalKey(Schema.String), + apiSchemas: Schema.optionalKey(Schema.String), + apiExtraSearchPath: Schema.optionalKey(Schema.String), + apiMaxRows: Schema.optionalKey(Schema.Finite), jwtSecret: Schema.optionalKey(Schema.String), anonKey: Schema.optionalKey(Schema.String), serviceRoleKey: Schema.optionalKey(Schema.String), publishableKey: Schema.optionalKey(Schema.String), secretKey: Schema.optionalKey(Schema.String), openaiApiKey: Schema.optionalKey(Schema.String), + cliVersion: Schema.optionalKey(Schema.String), }); export interface Config extends Schema.Schema.Type {} @@ -45,6 +54,16 @@ export const makeSpec = (): ProcessRecipeSpec => ({ ...(http === undefined ? {} : { PORT: String(http.port) }), HOSTNAME: container ? "0.0.0.0" : "127.0.0.1", }; + if (creation.config.databaseUrl !== undefined) { + // Studio encrypts a connection string from these for pg-meta; the bootstrap gives the + // `postgres` role the same managed password as the URL's admin role. + const db = yield* databaseConnection(creation.config.databaseUrl); + values.POSTGRES_HOST = db.host; + values.POSTGRES_PORT = db.port; + values.POSTGRES_DB = db.database; + values.POSTGRES_PASSWORD = db.password ?? "postgres"; + values.POSTGRES_USER_READ_WRITE = "postgres"; + } if (creation.config.pgmetaUrl !== undefined) values.STUDIO_PG_META_URL = creation.config.pgmetaUrl; if (creation.config.analyticsUrl !== undefined) @@ -53,6 +72,9 @@ export const makeSpec = (): ProcessRecipeSpec => ({ values.LOGFLARE_PRIVATE_ACCESS_TOKEN = creation.config.analyticsApiKey; values.NEXT_PUBLIC_ENABLE_LOGS = "true"; } + if (creation.config.analyticsBackend !== undefined) + values.NEXT_ANALYTICS_BACKEND_PROVIDER = creation.config.analyticsBackend; + if (jwt !== undefined) values.AUTH_JWT_SECRET = jwt; if (anonKey !== undefined) values.SUPABASE_ANON_KEY = anonKey; if (serviceRoleKey !== undefined) values.SUPABASE_SERVICE_KEY = serviceRoleKey; if (creation.config.publishableKey !== undefined) @@ -62,20 +84,32 @@ export const makeSpec = (): ProcessRecipeSpec => ({ if (creation.config.apiUrl !== undefined) values.SUPABASE_URL = creation.config.apiUrl; if (creation.config.publicApiUrl !== undefined) values.SUPABASE_PUBLIC_URL = creation.config.publicApiUrl; + if (creation.config.apiSchemas !== undefined) + values.PGRST_DB_SCHEMAS = creation.config.apiSchemas; + if (creation.config.apiExtraSearchPath !== undefined) + values.PGRST_DB_EXTRA_SEARCH_PATH = creation.config.apiExtraSearchPath; + if (creation.config.apiMaxRows !== undefined) + values.PGRST_DB_MAX_ROWS = String(creation.config.apiMaxRows); if (creation.config.openaiApiKey !== undefined) values.OPENAI_API_KEY = creation.config.openaiApiKey; if (creation.config.functionsRoot !== undefined) values.EDGE_FUNCTIONS_MANAGEMENT_FOLDER = container ? "/__supabase_functions" : creation.config.functionsRoot; + if (creation.config.snippetsRoot !== undefined) + values.SNIPPETS_MANAGEMENT_FOLDER = container + ? CONTAINER_SNIPPETS_ROOT + : creation.config.snippetsRoot; if (creation.config.functionsUrl !== undefined) values.EDGE_FUNCTIONS_URL = creation.config.functionsUrl; + if (creation.config.cliVersion !== undefined) + values.CURRENT_CLI_VERSION = creation.config.cliVersion; return values; }), args: () => Effect.succeed([]), mounts: (creation) => - Effect.succeed( - creation.config.functionsRoot === undefined + Effect.succeed([ + ...(creation.config.functionsRoot === undefined ? [] : [ { @@ -83,7 +117,16 @@ export const makeSpec = (): ProcessRecipeSpec => ({ target: "/__supabase_functions", readOnly: true, }, - ], - ), + ]), + ...(creation.config.snippetsRoot === undefined + ? [] + : [ + { + source: creation.config.snippetsRoot, + target: CONTAINER_SNIPPETS_ROOT, + readOnly: false, + }, + ]), + ]), startupCommands: [], }); diff --git a/packages/stack/src/services/Studio.unit.test.ts b/packages/stack/src/services/Studio.unit.test.ts new file mode 100644 index 0000000000..e471f95578 --- /dev/null +++ b/packages/stack/src/services/Studio.unit.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { makeSpec, type Creation } from "./Studio.ts"; + +const creation = (config: Creation["config"]): Creation => ({ service: "studio", config }); + +it.effect("points Studio's pg-meta connection at the bound database as the postgres role", () => + Effect.gen(function* () { + const env = yield* makeSpec().env( + creation({ + databaseUrl: + "postgresql://supabase_admin:managed-secret@host.docker.internal:54329/postgres", + }), + new Map(), + true, + ); + expect(env).toMatchObject({ + POSTGRES_HOST: "host.docker.internal", + POSTGRES_PORT: "54329", + POSTGRES_DB: "postgres", + POSTGRES_PASSWORD: "managed-secret", + POSTGRES_USER_READ_WRITE: "postgres", + }); + }), +); + +it.effect("mounts the snippets folder read-write and names it per runtime", () => + Effect.gen(function* () { + const spec = makeSpec(); + const config = creation({ snippetsRoot: "/project/supabase/snippets" }); + const containerEnv = yield* spec.env(config, new Map(), true); + const nativeEnv = yield* spec.env(config, new Map(), false); + expect(containerEnv.SNIPPETS_MANAGEMENT_FOLDER).toBe("/__supabase_snippets"); + expect(nativeEnv.SNIPPETS_MANAGEMENT_FOLDER).toBe("/project/supabase/snippets"); + expect(yield* spec.mounts(config, { container: true })).toEqual([ + { source: "/project/supabase/snippets", target: "/__supabase_snippets", readOnly: false }, + ]); + }), +); From 315dbcd13f810547a206b444ea3dcbad058e3357 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 29 Sep 2026 06:05:47 +0000 Subject: [PATCH 21/71] fix(stack): name containers by project and service (#6867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** Every experimental-stack container was named `supabase-` with no service or grouping labels. With lazy wake-up and idle stops, Docker Desktop and OrbStack showed a stream of anonymous containers starting and stopping. Containers are now named after the project and service, and each stack groups as one compose project. ### Before ```mermaid flowchart LR W[Lazy wake] --> N["supabase-3f2a…-uuid
no service or group labels"] ``` ### After ```mermaid flowchart LR W[Lazy wake] --> N["supabase-myapp-studio-5f91…"] O[Migration step] --> T["supabase-myapp-storage-task-a16a…"] N --> G["Grouped as one compose project per stack"] T --> G ``` ### What changed - Container names are `supabase---<12 hex>`. One-shot migration and startup containers add a `-task` segment. `` is the project directory name, plus the stack name when it isn't `default`. When the project root has no directory name, it is the stack name alone. Segments are limited to Docker's name alphabet and a bounded length. - New labels: - `com.supabase.service=`, the plain service name, so log collectors can route by it. - `com.docker.compose.project=supabase--`, lowercase. - `com.docker.compose.service=`. - `com.docker.compose.oneoff=True` on one-shot containers. - Existing `com.supabase.*` labels are unchanged. Cleanup still selects containers by label, never by name. ## Linked issue None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- packages/stack/src/Owner.ts | 4 ++ packages/stack/src/StackHost.ts | 3 + packages/stack/src/host/CommandRunner.ts | 4 ++ packages/stack/src/identity/Identity.ts | 16 ++++- .../stack/src/identity/Identity.unit.test.ts | 33 ++++++++++ .../src/runtime/Container.integration.test.ts | 62 +++++++++++++++++++ packages/stack/src/runtime/Container.ts | 24 ++++++- packages/stack/src/runtime/ContainerName.ts | 37 +++++++++++ .../src/runtime/ContainerName.unit.test.ts | 58 +++++++++++++++++ packages/stack/src/services/Catalog.ts | 1 + .../src/services/Database.integration.test.ts | 4 +- packages/stack/src/services/Database.ts | 6 ++ packages/stack/src/services/ProcessRecipe.ts | 4 ++ packages/stack/src/services/Recipe.ts | 2 + 14 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 packages/stack/src/identity/Identity.unit.test.ts create mode 100644 packages/stack/src/runtime/ContainerName.ts create mode 100644 packages/stack/src/runtime/ContainerName.unit.test.ts diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index e23b037923..87513c94b1 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -61,6 +61,7 @@ import { } from "./services/Catalog.ts"; import type { CatalogError } from "./services/Recipe.ts"; import * as Container from "./runtime/Container.ts"; +import { projectSegmentFor } from "./identity/Identity.ts"; import { stackError, type OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; @@ -176,12 +177,14 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { >(); const ownerScope = Context.get(services, Scope.Scope); const crypto = Context.get(services, Crypto.Crypto); + const path = Context.get(services, Path.Path); const network = yield* Network.Service; const orchestrator = yield* Orchestrator.make(); const helpers = yield* makeDockerHelperRegistry(yield* crypto.randomUUIDv4); const definitionGate = yield* Semaphore.make(1); const draining = yield* Ref.make(false); const { id: stackId, runtime } = options.saved; + const project = projectSegmentFor(options.saved.identity, path); const routeKeys = { publishableKey: options.saved.credentials?.publishableKey ?? "", secretKey: options.saved.credentials?.secretKey ?? "", @@ -285,6 +288,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { makeServiceRecipe(creation, { stackId, instanceId: id, + project, root: options.root, cacheRoot: options.cacheRoot, runtime, diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 53abcb01eb..cf6928c543 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -38,6 +38,7 @@ import { type HostEndpoint, type ShutdownFailure, } from "./HostProcess.ts"; +import { projectSegmentFor } from "./identity/Identity.ts"; import * as Owner from "./Owner.ts"; import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; import * as State from "./State.ts"; @@ -425,6 +426,7 @@ export const runStackHost = Effect.fn("StackHost.run")( const dataRootPath = path.join(options.stateRoot, saved.id, "data"); yield* fs.makeDirectory(dataRootPath, { recursive: true }); const dataRoot = yield* fs.realPath(dataRootPath); + const project = projectSegmentFor(saved.identity, path); yield* Owner.sweepContainers(saved, dataRoot).pipe( Effect.mapError((cause) => hostError( @@ -443,6 +445,7 @@ export const runStackHost = Effect.fn("StackHost.run")( }), CommandRunner.layer({ stackId: saved.id, + project, root: dataRoot, cacheRoot: options.cacheRoot, runtime: saved.runtime, diff --git a/packages/stack/src/host/CommandRunner.ts b/packages/stack/src/host/CommandRunner.ts index a383c69067..c88eb60229 100644 --- a/packages/stack/src/host/CommandRunner.ts +++ b/packages/stack/src/host/CommandRunner.ts @@ -71,6 +71,7 @@ const failure = (cause: unknown) => /** Creates an attached byte-stream runner whose invocation scope owns each finite process. */ const makeCommandRunner = (options: { readonly stackId: string; + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; @@ -218,6 +219,8 @@ const makeCommandRunner = (options: { image, stackId: options.stackId, instanceId: jobId, + service: initialization?.service ?? "database", + project: options.project, env: postgresCommand?.env ?? initialization?.env ?? {}, args: postgresCommand?.args ?? initialization?.args ?? [], entrypoint: @@ -299,6 +302,7 @@ const makeCommandRunner = (options: { export const layer = (options: { readonly stackId: string; + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; diff --git a/packages/stack/src/identity/Identity.ts b/packages/stack/src/identity/Identity.ts index bef867a950..379d04a2a4 100644 --- a/packages/stack/src/identity/Identity.ts +++ b/packages/stack/src/identity/Identity.ts @@ -1,9 +1,11 @@ -import { Crypto, Effect, FileSystem, Schema } from "effect"; +import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { InvalidProjectRootError, InvalidStackIdentityError } from "./Errors.ts"; import { StackIdSchema } from "./StackId.ts"; import { resolveGitBranchContext } from "./GitBranchContext.ts"; +const DEFAULT_STACK_NAME = "default"; + export interface StackIdentity { readonly projectRoot: string; readonly branchContext: string; @@ -88,7 +90,7 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f }); } - const stackName = options.name ?? "default"; + const stackName = options.name ?? DEFAULT_STACK_NAME; if (stackName.trim().length === 0) { return yield* identityFailure("The stack name must not be blank", { name: options.name }); } @@ -110,3 +112,13 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f ), ); }); + +/** + * Names a stack for container names and grouping labels: the project root's folder name plus any + * non-default stack name, or the stack name alone when the root has no folder name. + */ +export const projectSegmentFor = (identity: StackIdentity, path: Path.Path): string => { + const base = path.basename(identity.projectRoot); + if (base.trim().length === 0) return identity.stackName; + return identity.stackName === DEFAULT_STACK_NAME ? base : `${base}-${identity.stackName}`; +}; diff --git a/packages/stack/src/identity/Identity.unit.test.ts b/packages/stack/src/identity/Identity.unit.test.ts new file mode 100644 index 0000000000..b714d0e820 --- /dev/null +++ b/packages/stack/src/identity/Identity.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Path } from "effect"; +import { projectSegmentFor } from "./Identity.ts"; + +const identity = (projectRoot: string, stackName = "default") => ({ + projectRoot, + branchContext: "ordinary-workspace", + stackName, +}); + +describe("projectSegmentFor", () => { + it.effect("uses the project folder name", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/work/my.app"), path)).toBe("my.app"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("appends a non-default stack name so sibling stacks stay distinguishable", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/work/my.app", "test"), path)).toBe("my.app-test"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to the stack name for the filesystem root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/"), path)).toBe("default"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index d2fc743df5..922332501e 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -11,6 +11,7 @@ import { Layer, Option, Ref, + Schema, Sink, Stream, } from "effect"; @@ -137,6 +138,53 @@ describe("container process adapter", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("names and labels a service container for compose-style grouping", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "9".repeat(64), + instanceId: "naming-service", + project: "My Cool App", + service: "auth", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + expect(process.id).toMatch(/^supabase-My-Cool-App-auth-[0-9a-f]{12}$/u); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.service"]).toBe("auth"); + expect(labels["com.docker.compose.project"]).toBe(`supabase-my-cool-app-${"9".repeat(12)}`); + expect(labels["com.docker.compose.service"]).toBe("auth"); + expect(labels["com.docker.compose.oneoff"]).toBeUndefined(); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("marks a one-shot container's name and compose labels as a task", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "9".repeat(64), + instanceId: "naming-task", + project: "My Cool App", + service: "auth", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(process.id).toMatch(/^supabase-My-Cool-App-auth-task-[0-9a-f]{12}$/u); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.service"]).toBe("auth"); + expect(labels["com.docker.compose.service"]).toBe("auth"); + expect(labels["com.docker.compose.oneoff"]).toBe("True"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "publishes two private ports and keeps the second service alive after the first stops", () => @@ -1169,6 +1217,20 @@ const exists = (id: string) => return Number(yield* child.exitCode) === 0; }); +const inspectLabels = (id: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["inspect", "--format={{json .Config.Labels}}", id], { + stdin: "ignore", + }), + ); + const output = yield* child.stdout.pipe(Stream.decodeText, Stream.mkString); + return yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(output.trim()); + }); + const removeExternally = (id: string) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 441bdde76e..9b7742fe24 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -18,6 +18,7 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { identifyContainer } from "./ContainerName.ts"; export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly operation: string; @@ -30,6 +31,10 @@ interface ContainerSpec { readonly image: string; readonly stackId: string; readonly instanceId: string; + /** Labels the container with its service kind so stack log collectors can route it. */ + readonly service?: string; + /** Groups this stack's containers under one name in Docker Desktop/OrbStack. */ + readonly project?: string; readonly env: Readonly>; readonly args?: ReadonlyArray; readonly entrypoint?: string; @@ -261,6 +266,7 @@ export const makeContainerRuntime = (options: { const launch = Effect.fn("Container.launch")(function* ( spec: ContainerSpec, interactive = false, + oneOff = false, ) { const owner = yield* Scope.Scope; const image = (yield* Ref.get(mirrored)).get(spec.image) ?? spec.image; @@ -292,7 +298,7 @@ export const makeContainerRuntime = (options: { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => errorFor("identity", cause)), ); - const name = `supabase-${token}`; + const { name, composeProject, composeService } = identifyContainer(spec, token, oneOff); const args = [ "create", "--pull", @@ -309,6 +315,12 @@ export const makeContainerRuntime = (options: { `com.supabase.instance=${spec.instanceId}`, "--label", `com.supabase.stack-root=${stackRoot}`, + ...(spec.service === undefined ? [] : ["--label", `com.supabase.service=${spec.service}`]), + "--label", + `com.docker.compose.project=${composeProject}`, + "--label", + `com.docker.compose.service=${composeService}`, + ...(oneOff ? ["--label", "com.docker.compose.oneoff=True"] : []), "--env-file", envPath, ...(spec.mounts ?? []).flatMap((mount) => [ @@ -378,7 +390,8 @@ export const makeContainerRuntime = (options: { "--all", "--no-trunc", "--filter", - `name=^/?${name}$`, + // Docker matches this as a regex; `.` is the only metacharacter a name can hold. + `name=^/?${name.replaceAll(".", "\\.")}$`, "--format", "{{.State}}", ], @@ -549,7 +562,12 @@ export const makeContainerRuntime = (options: { }), ); }); - return { prepare, prepareImage, launch, launchCommand: (spec) => launch(spec, true) }; + return { + prepare, + prepareImage, + launch, + launchCommand: (spec) => launch(spec, true, true), + }; }); export const removeStackContainers = Effect.fn("Container.removeStackContainers")( diff --git a/packages/stack/src/runtime/ContainerName.ts b/packages/stack/src/runtime/ContainerName.ts new file mode 100644 index 0000000000..13c845ef42 --- /dev/null +++ b/packages/stack/src/runtime/ContainerName.ts @@ -0,0 +1,37 @@ +/** The stack fields that name a container and group it for Docker Desktop/OrbStack. */ +interface ContainerIdentityInput { + readonly stackId: string; + readonly service?: string; + readonly project?: string; +} + +const nameSegment = (value: string): string => + value.replaceAll(/[^a-zA-Z0-9_.-]+/gu, "-").slice(0, 40); + +// Compose project names allow only lowercase letters, digits, dashes, and underscores. +const composeSegment = (value: string): string => + value + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/gu, "-") + .slice(0, 40); + +/** Names a container and sets compose grouping labels; one-shots get a `-task` segment. */ +export const identifyContainer = (spec: ContainerIdentityInput, token: string, oneOff: boolean) => { + const project = spec.project === undefined ? undefined : nameSegment(spec.project); + const service = spec.service === undefined ? undefined : nameSegment(spec.service); + const name = [ + "supabase", + project, + service, + oneOff ? "task" : undefined, + token.replaceAll("-", "").slice(0, 12), + ] + .filter((segment): segment is string => segment !== undefined && segment.length > 0) + .join("-"); + const composeProject = [ + "supabase", + spec.project === undefined ? "stack" : composeSegment(spec.project) || "stack", + spec.stackId.slice(0, 12).toLowerCase(), + ].join("-"); + return { name, composeProject, composeService: service ?? "task" }; +}; diff --git a/packages/stack/src/runtime/ContainerName.unit.test.ts b/packages/stack/src/runtime/ContainerName.unit.test.ts new file mode 100644 index 0000000000..a663dc7a6b --- /dev/null +++ b/packages/stack/src/runtime/ContainerName.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { identifyContainer } from "./ContainerName.ts"; + +const stackId = "0123456789abcdef0123"; +const token = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; + +describe("identifyContainer", () => { + it("names a service container after its project and service", () => { + expect( + identifyContainer({ stackId, project: "my-app", service: "studio" }, token, false), + ).toEqual({ + name: "supabase-my-app-studio-a1b2c3d4e5f6", + composeProject: "supabase-my-app-0123456789ab", + composeService: "studio", + }); + }); + + it("marks one-shot containers with a task segment but keeps the service for grouping", () => { + const identity = identifyContainer( + { stackId, project: "my-app", service: "auth" }, + token, + true, + ); + expect(identity.name).toBe("supabase-my-app-auth-task-a1b2c3d4e5f6"); + expect(identity.composeService).toBe("auth"); + }); + + it("keeps dotted folder names in the container name but not in the compose project", () => { + const identity = identifyContainer( + { stackId, project: "My.App", service: "rest" }, + token, + false, + ); + expect(identity.name).toBe("supabase-My.App-rest-a1b2c3d4e5f6"); + expect(identity.composeProject).toBe("supabase-my-app-0123456789ab"); + }); + + it("replaces characters outside the docker name alphabet and bounds the length", () => { + const identity = identifyContainer( + { stackId, project: `café ${"x".repeat(60)}`, service: "database" }, + token, + false, + ); + expect(identity.name).toMatch(/^supabase-caf-x+-database-a1b2c3d4e5f6$/u); + expect(identity.name.length).toBeLessThanOrEqual( + "supabase-".length + 40 + "-database-".length + 12, + ); + expect(identity.composeProject).toMatch(/^supabase-[a-z0-9_-]{1,40}-0123456789ab$/u); + }); + + it("falls back to generic segments when the project and service are unknown", () => { + expect(identifyContainer({ stackId }, token, true)).toEqual({ + name: "supabase-task-a1b2c3d4e5f6", + composeProject: "supabase-stack-0123456789ab", + composeService: "task", + }); + }); +}); diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index ce36b7944a..54d264eb8c 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -322,6 +322,7 @@ export const makeServiceRecipe = Effect.fn("Catalog.makeServiceRecipe")( const component = yield* makeDatabase({ stackId: options.stackId, instanceId: options.instanceId, + project: options.project, root: options.root, cacheRoot: options.cacheRoot, runtime: options.runtime, diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index 33731f14df..3b2cc63541 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -490,9 +490,11 @@ describe("database component", { timeout: 180_000 }, () => { "--format", "{{.Names}}", ]); + // The shared volume helper carries the same stack-root/instance labels; exclude it by name. const containers = listed.output .split("\n") - .filter((name) => /^supabase-[0-9a-f]{8}-[0-9a-f-]+$/u.test(name)); + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !name.startsWith("supabase-db-helper-")); expect(containers).toHaveLength(1); const container = containers.join(""); const startedAt = yield* runDocker([ diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index fbcde0f5f3..3ae0ee0dae 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -121,6 +121,8 @@ export class DatabaseError extends Data.TaggedError("DatabaseError")<{ export interface DatabaseOptions { readonly stackId: StackId | string; readonly instanceId: string; + /** Project folder name; the container runtime sanitizes it into names and grouping labels. */ + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: DatabaseRuntime; @@ -539,6 +541,8 @@ export const makeDatabase = ( image: artifact.image, stackId: String(options.stackId), instanceId: options.instanceId, + service: "database", + project: options.project, entrypoint: "/usr/bin/busybox", args, env: {}, @@ -720,6 +724,8 @@ export const makeDatabase = ( image: image.image, stackId: String(options.stackId), instanceId: options.instanceId, + service: "database", + project: options.project, env: { PGDATA: "/var/lib/postgresql/data", PGSODIUM_KEY_FILE: "/etc/postgresql-custom/pgsodium_root.key", diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 9d1bf18e04..3b8265e360 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -732,6 +732,8 @@ export const makeProcessRecipe = image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, + service: spec.service, + project: options.project, env: command.env, entrypoint: command.entrypoint, args: command.args, @@ -780,6 +782,8 @@ export const makeProcessRecipe = image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, + service: spec.service, + project: options.project, env: yield* spec.env(context.config, containerDesired, true), entrypoint: spec.containerEntrypoint?.(context.config), args: yield* spec.args(context.config, containerDesired, { container: true }), diff --git a/packages/stack/src/services/Recipe.ts b/packages/stack/src/services/Recipe.ts index 93cc5df0e6..b28f36b438 100644 --- a/packages/stack/src/services/Recipe.ts +++ b/packages/stack/src/services/Recipe.ts @@ -45,6 +45,8 @@ export const serviceCreation = < export interface CatalogOptions { readonly stackId: string; readonly instanceId: string; + /** Project folder name; the container runtime sanitizes it into names and grouping labels. */ + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: CatalogRuntime; From 7894283333a1c706f43865cb516235779ceb50a2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 06:17:01 +0000 Subject: [PATCH 22/71] feat(stack): add a testing entrypoint and derive the Promise API (#6841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The package had no supported way to use a stack from tests. Test authors picked state, cache and project roots themselves, wrote service creations by hand and nested try/finally teardown, and CLI tests imported package test helpers by relative path. The Promise API was also a hand-written copy of the Effect API, and it had already drifted from it. ## Change - `@supabase/stack/testing`: - `createTestStack` works with `await using`, returns typed service handles, and provides `checkpoint` and `reset`. - `makeTestStack` is the scoped Effect variant. - Test stacks use the session lifetime, eager composition, automatic ports, and per-user shared roots. - Checkpoints are instance-scoped snapshots. Parallel stacks cannot evict them, and destroy removes them. `reset` never wipes data for a missing checkpoint, and it always brings the composition back. - The Promise client is derived from the Effect handles, with type-level parity tests. Only operations that return handles are adapted; observations and other data pass through unchanged. - Cleanup: - The unused `./tools` export and the duplicate default re-exports are removed. - `StackIdentityInput` is now `StackKeysInput`. - The default runtime is chosen in the package. - CLI tests use a CLI-local cleanup helper. ## Stack Part 8 of 8 of the stack package simplification, based on #6840. Review and merge in order: 1. #6834 fix(stack): stop Postgres containers with a fast shutdown 2. #6835 fix(stack): harden readiness, port claims, and container storage 3. #6836 refactor(stack): flatten the owner process layers 4. #6837 refactor(stack): unify the database snapshot protocol across engines 5. #6838 feat(stack): lease owners with a lock and bind session stacks to creators 6. #6839 feat(stack): ship the functions bootstrap with the package 7. #6840 refactor(stack): own composition policy and stack lookup in the package 8. #6841 feat(stack): add a testing entrypoint and derive the Promise API ← this PR --------- Co-authored-by: Claude Opus 5.5 --- .../pg-dump.native.integration.test.ts | 2 +- .../stack-catalog-setup.integration.test.ts | 2 +- .../cli/src/command-internal/stack-runtime.ts | 7 +- .../test-db.native.integration.test.ts | 2 +- .../commands/db/reset/reset.stack.e2e.test.ts | 2 +- .../stack/logs/logs.integration.test.ts | 2 +- .../stack/restart/restart.integration.test.ts | 2 +- .../functions/serve/serve.stack.e2e.test.ts | 2 +- .../squash/squash.native.integration.test.ts | 2 +- apps/cli/tests/helpers/stack-cleanup.ts | 6 +- packages/stack/ARCHITECTURE.md | 33 +- packages/stack/README.md | 54 ++- packages/stack/package.json | 1 + packages/stack/src/Artifacts.ts | 8 + packages/stack/src/Owner.integration.test.ts | 2 +- packages/stack/src/Owner.ts | 12 +- packages/stack/src/PromiseClient.ts | 432 ++++++++++++++++++ packages/stack/src/PromiseClient.unit.test.ts | 57 +++ packages/stack/src/Rpc.ts | 10 +- packages/stack/src/State.ts | 5 +- packages/stack/src/composition/Supabase.ts | 4 +- .../stack/src/defaults.integration.test.ts | 30 +- packages/stack/src/effect.ts | 49 +- packages/stack/src/host/Credentials.ts | 4 +- packages/stack/src/index.ts | 420 ++--------------- packages/stack/src/internal/artifacts.ts | 1 + .../stack/src/promise-api.integration.test.ts | 72 +++ packages/stack/src/promise-api.unit.test.ts | 156 +++++++ packages/stack/src/public.e2e.test.ts | 40 +- packages/stack/src/services/Catalog.ts | 11 +- packages/stack/src/services/Database.ts | 85 ++-- .../DatabaseSnapshot.integration.test.ts | 60 ++- .../stack/src/services/DatabaseSnapshot.ts | 99 ++-- packages/stack/src/services/ServiceConfig.ts | 4 +- .../DockerDatabaseStorage.integration.test.ts | 15 +- .../src/storage/DockerDatabaseStorage.ts | 100 ++-- .../src/storage/DockerSnapshotBackend.ts | 4 +- .../stack/src/testing.integration.test.ts | 246 ++++++++++ packages/stack/src/testing.ts | 367 +++++++++++++++ packages/stack/tests/test-stack-client.ts | 58 +++ 40 files changed, 1860 insertions(+), 608 deletions(-) create mode 100644 packages/stack/src/PromiseClient.ts create mode 100644 packages/stack/src/PromiseClient.unit.test.ts create mode 100644 packages/stack/src/promise-api.integration.test.ts create mode 100644 packages/stack/src/promise-api.unit.test.ts create mode 100644 packages/stack/src/testing.integration.test.ts create mode 100644 packages/stack/src/testing.ts create mode 100644 packages/stack/tests/test-stack-client.ts diff --git a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts index cc68deebae..1b163a63d6 100644 --- a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts @@ -12,7 +12,7 @@ import { DockerRun } from "./docker-run.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 10607997c0..8601b9b809 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -20,7 +20,7 @@ import { postgres } from "@supabase/stack/commands"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import type { Command } from "@supabase/stack/commands"; import { stackCatalogSetupLayer, StackCatalogSetup } from "./stack-catalog-setup.ts"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const jwtSecret = "stack-catalog-setup-integration-secret"; diff --git a/apps/cli/src/command-internal/stack-runtime.ts b/apps/cli/src/command-internal/stack-runtime.ts index 3f4834f783..3894019391 100644 --- a/apps/cli/src/command-internal/stack-runtime.ts +++ b/apps/cli/src/command-internal/stack-runtime.ts @@ -1,3 +1,4 @@ +import { defaultRuntime } from "@supabase/stack/internal/artifacts"; import { Data, Effect } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -49,10 +50,6 @@ const engineReachable = ( Effect.orElseSucceed(() => false), ); -const nativeSupported = (platform: string, arch: string): boolean => - (platform === "linux" && (arch === "x64" || arch === "arm64")) || - (platform === "darwin" && arch === "arm64"); - /** * Notice for a new stack whose automatic selection skipped Docker, since that runtime is saved with * the stack; `undefined` when the runtime was requested, saved, or Docker. @@ -78,7 +75,7 @@ export const selectStackRuntime = Effect.fn("StackRuntime.select")(function* ( if (yield* engineReachable(spawner, probe)) return probe.runtime; } const { platform, arch } = yield* RuntimeInfo; - if (nativeSupported(platform, arch)) return "native"; + if (defaultRuntime({ os: platform, arch }) === "native") return "native"; return yield* new StackRuntimeSelectionError({ message: `Neither Docker nor Podman is reachable, and native stacks are not supported on ${platform}/${arch}.`, suggestion: "Start Docker or Podman, then rerun the command.", diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index e638e3bf94..d8f52694fb 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -26,7 +26,7 @@ import { StackError } from "@supabase/stack/effect"; import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; import type { Stack } from "@supabase/stack/effect"; import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index fe5a8d1def..f86b7e44f8 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -7,7 +7,7 @@ import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 2 * 60_000; diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 509be704b2..5caba8c782 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -9,7 +9,7 @@ import { } from "../../../../../tests/helpers/command-mocks.ts"; import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; import { stackLogs } from "./logs.handler.ts"; -import { destroyTestStack } from "../../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../../tests/helpers/stack-cleanup.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); const fixture = Effect.fn("StackLogsTest.fixture")(function* ( diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts index c38a8091b1..ab24e7622a 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -10,7 +10,7 @@ import { } from "../../../../../tests/helpers/command-mocks.ts"; import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; import { stackRestart } from "./restart.handler.ts"; -import { destroyTestStack } from "../../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../../tests/helpers/stack-cleanup.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); const fixture = Effect.fn("StackRestartTest.fixture")(function* () { diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 4c1ce7d9a7..5bc41babe2 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -7,7 +7,7 @@ import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; -import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const jwtSecret = "functions-serve-stack-e2e-secret-at-least-32-characters"; const nativeSupported = diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index d14f20e6be..db17d0494a 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -28,7 +28,7 @@ import { BundledPostgresClient } from "../../../command-internal/bundled-postgre import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; -import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/tests/helpers/stack-cleanup.ts b/apps/cli/tests/helpers/stack-cleanup.ts index 1a33dff392..c5326195a5 100644 --- a/apps/cli/tests/helpers/stack-cleanup.ts +++ b/apps/cli/tests/helpers/stack-cleanup.ts @@ -1,6 +1,10 @@ +import type { Stack } from "@supabase/stack/effect"; import { Cause, Effect, Exit } from "effect"; import type { StackApi } from "../../src/command-internal/stack-api.ts"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; + +/** Destroys a test stack, failing the test when teardown fails. */ +export const destroyTestStack = (stack: Stack): Effect.Effect => + stack.destroy.pipe(Effect.orDie); export const destroyTestStacks = ( api: StackApi["Service"], diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 26629a8355..51017714da 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -57,7 +57,9 @@ Organize by cohesive responsibilities. The package shape is: - `runtime/`: native and container adapters. - `Commands.ts`: public finite-command descriptors. - `effect.ts`: Effect-facing composition and services. -- `index.ts`: Promise-facing public boundary. +- `index.ts`: Promise-facing public boundary. `PromiseClient.ts` derives its types from the Effect handles and adapts them by shape: Effects become cancellable calls, Streams async iterables, and returned handles are adapted recursively. Only operations whose inputs differ (plain creations, Promise command sinks) and the per-kind creation type are written by hand. +- `testing.ts`: disposable, composed session stacks for tests, with database checkpoints, in Promise and Effect forms. +- `Defaults.ts`: shared local-development credentials, exported once as `./defaults`. This is navigational guidance, not a required file scaffold. Split modules when a responsibility needs it; avoid one folder or interface per operation. Keep service definitions narrow, with graph edges and input wiring in composition. Do not introduce capabilities, projections, recovery journals, reservations, public sleep APIs, or extra lifecycle states to force this shape. @@ -382,16 +384,19 @@ CLI policy remains CLI policy: SQL scripts, migrations, seeds, hosted targets, o Expose one awaited `stack.commands.run` operation. PostgreSQL client commands and one-shot service initialization share the same owner-side runner. Initialization runs a service recipe without registering a service instance or changing the service graph. ```ts -import { postgres } from "@supabase/stack/commands"; +import { postgres } from "@supabase/stack"; // Connection values are plain data, rendered for the stack runtime. const { databaseUrl } = await database.credentials({ from: "runtime" }); -const result = await stack.commands.run(postgres.pgDump({ major: 17 }), { - args: ["--dbname", databaseUrl, "--schema-only", "--no-owner"], - stdout: (bytes) => destination.write(bytes), - stderr: (bytes) => diagnostics.write(bytes), - signal, -}); +const result = await stack.commands.run( + postgres.pgDump({ major: 17 }), + { + args: ["--dbname", databaseUrl, "--schema-only", "--no-owner"], + stdout: (bytes) => destination.write(bytes), + stderr: (bytes) => diagnostics.write(bytes), + }, + { signal }, +); // result: { jobId, exitCode } @@ -476,10 +481,12 @@ During Starting, acquire the exclusive stack lease, load the instance definition **Release handshake.** `/identity` reports the owner's release: the package version plus a build identifier. The identifier is a digest of this package's module sources and its Effect version: the CLI build scripts embed it in every compiled binary and a source checkout computes it, so a binary and a source run of the same sources interoperate, and any change to the owner or its protocol is a new release. Operations fail with an error asking the user to stop or destroy the stack when the releases differ. `stop` and `destroy` use `POST /shutdown` with the bearer secret and a `{ "destroy": boolean }` body, which do not depend on the RPC schema and so reach owners of any release. -**Session lifetime.** A session stack is registered by its owner under the lease, so it never exists without a live owner except after that owner dies. Its spawner keeps the owner's stdin pipe open for the life of the creating handle. End of input means the creator is gone, whether it closed the handle or its process died: the owner destroys the stack and exits. Only the creating handle starts a session stack's owner; other handles attach. +**Session lifetime.** A session stack is registered by its owner under the lease, so it never exists without a live owner except after that owner dies. Its spawner keeps the owner's stdin pipe open for the life of the creating handle. End of input means the creator is gone, whether it closed the handle or its process died: the owner destroys the stack and exits. Only the creating handle starts a session stack's owner; other handles attach. `create({ startOwner: true })` registers a detached stack through its owner the same way; an owner whose startup fails removes the registration it made, so a failed or interrupted first launch leaves no stack behind. **Orphan sweep.** Stack-labelled containers and session stacks exist only while their lease is held. After readiness, each owner visits every other stack in its state root in the background, with a bounded time per stack. It skips stacks whose lease is held. For a free lease it takes that lease for the duration of the visit, publishing a sweeper record in `owner.json` so clients wait for the visit instead of mistaking it for a starting owner, removes containers labelled with the stack and its data root, and destroys the stack through the owner's own destroy path when its lifetime is `session`. Filtering on the data-root label keeps other state roots untouched. Creating a stack whose identity belongs to a dead session stack reclaims that stack the same way first. +**Unreachable engine.** An owner of a container stack fails startup with a `runtime-unavailable` reason when its first container sweep finds the engine CLI missing or its daemon not listening; permission, TLS, authentication and timeout failures are ordinary startup errors. `destroy` then proceeds without an owner: it takes the free lease, publishing a sweeper record like the orphan sweep, refuses when any stack data directory cannot be deleted by the current user, removes the host data and the registration with its port claims, and returns the shell commands that remove the stack's containers and engine-volume data once the engine runs. `stop` without a live owner already succeeds without contacting the engine. + During Serving, keep the owner alive independently of callers. Sleeping instances still need its public listeners. This is process lifetime management, not automatic service restart or continuous reconciliation. Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and commands, then synchronously removes containers labeled with the stack and canonical data root before the host exits. After forced termination, the orphan sweep removes leftover containers. @@ -654,8 +661,8 @@ Expose `saveSnapshot` and `restoreSnapshot` on `DatabaseInstance` only. The comm ```ts interface DatabaseInstance extends ServiceInstance { readonly service: "database"; - saveSnapshot(key: string): Promise; - restoreSnapshot(key: string): Promise; + saveSnapshot(key: string, options?: { scope?: "cache" | "instance" }): Promise; + restoreSnapshot(key: string, options?: { scope?: "cache" | "instance" }): Promise; } // `baseline` has already been initialized; `shadow` is a fresh instance. @@ -669,7 +676,7 @@ await shadow.start(); await shadow.ready(); ``` -The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. Each store retains three entries by last use; saving a key replaces the previous complete entry for that key. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. +The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. Keep the contract narrow: @@ -677,7 +684,7 @@ Keep the contract narrow: - Restore requires a confirmed stopped instance with empty data. Validate format, artifact/runtime compatibility and initialization profile before installing restored data. A missing key returns `false`; a compatible published entry returns `true`; reject a nonempty target rather than overwriting it. - Both operations occupy the instance's existing serial operation gate and leave lifecycle stopped. Queued start, destroy or another storage operation waits for settlement and revalidates. No new lifecycle states are necessary; the observable pending operation identifies snapshot work. An armed wake route is not a substitute for explicit stop. - Native snapshots copy or clone the host data; container snapshots copy database data through a managed volume and helper. Docker data normally lives in a managed volume, while existing host data can be retained through the host-backed fallback. The host storage marker detects a missing or mismatched Docker volume; deleting that volume loses its database data. -- Restore transfers compatible database contents, not the source instance's identity, public port claims or composition membership. The target retains its own data location and configuration, with database-specific credentials reconciled before readiness. Snapshots survive destruction of the source instance because their managed storage is separate. +- Restore transfers compatible database contents, not the source instance's identity, public port claims or composition membership. The target retains its own data location and configuration, with database-specific credentials reconciled before readiness. Cache snapshots survive destruction of the source instance because their managed storage is separate; instance snapshots restore only into their own instance. These are physical database snapshots for the cache use case. A `pg_dump` invocation remains an ordinary client command for logical exports. CLI code owns cache keys, migrations and the decision to fall back to rebuilding a baseline; managed storage owns publication and retention. The snapshot API does not acquire CLI cache policy. diff --git a/packages/stack/README.md b/packages/stack/README.md index 169d6f7b62..13d5880023 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -55,7 +55,7 @@ await database.saveSnapshot("baseline"); await stack.close(); // disconnects this client ``` -`start` and `ready` are separate operations. `restart({ config })` replaces recipe configuration while retaining the instance identity and endpoint intentions. A health failure leaves a launched process running and observable; it does not prevent `stop`. Database snapshots require a stopped instance with wake disabled. `saveSnapshot(key)` publishes complete data to managed backend storage and replaces the previous entry for that key; `restoreSnapshot(key)` returns `false` on a miss and `true` after restoring a compatible entry. Managed retention may evict older keys, while snapshots survive destruction of the source stack. Other service handles have no snapshot methods. +`start` and `ready` are separate operations. `restart({ config })` replaces recipe configuration while retaining the instance identity and endpoint intentions. A health failure leaves a launched process running and observable; it does not prevent `stop`. Database snapshots require a stopped instance with wake disabled. `saveSnapshot(key)` publishes complete data to managed backend storage and replaces the previous entry for that key; `restoreSnapshot(key)` returns `false` on a miss and `true` after restoring a compatible entry. By default snapshots live in the shared cache, whose retention may evict older keys, and survive destruction of the source stack. `{ scope: "instance" }` keeps a snapshot with the instance instead: it is never evicted, restores only into that instance, survives `resetData`, and is removed when the instance is destroyed. Other service handles have no snapshot methods. Creating a service records its definition. Configured public ports are bound during startup and retained across normal stop/start and owner reopening. An occupied saved port reports a conflict instead of moving. Automatic ports avoid numbers saved by any stack under the same `stateRoot`; a fixed port is decided by binding it, so another stack's saved port blocks only while something listens on it; that conflict names the stack that saved the port. Omitted public endpoints are not exposed. @@ -157,21 +157,39 @@ Bindings supply ordinary configuration values; a URL alone never creates a depen Exit confirmation is bounded. If cleanup is acknowledged but owner exit cannot be confirmed, the operation fails with `operation: "shutdown-exit"` and the owner PID in the message. A failed or cancelled call does not guarantee that teardown has completed. Do not start or restart the same stack concurrently with whole-stack shutdown; separate stacks remain independent. -A session stack is destroyed when its creating client closes. A detached test stack needs explicit teardown, because closing a client does not own its lifetime: +Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observations include the currently bound public endpoints, including listeners for sleeping services. Credentials default to host addressing. Use `from: "runtime"` for a URL passed to a service or command container. + +## Testing + +`@supabase/stack/testing` creates a disposable, ready stack for a test: ```ts -try { - // Exercise the stack. -} finally { - try { - await stack.destroy(); - } finally { - await stack.close(); - } -} +import { createTestStack } from "@supabase/stack/testing"; + +await using test = await createTestStack({ services: ["database", "rest"] }); +const { databaseUrl } = await test.services.database.credentials(); + +await test.checkpoint("seeded"); // after loading fixtures +// Exercise the stack. +await test.reset("seeded"); // discard writes made since the checkpoint ``` -Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observations include the currently bound public endpoints, including listeners for sleeping services. Credentials default to host addressing. Use `from: "runtime"` for a URL passed to a service or command container. +The stack is a session stack composed with `composition.supabase` and `{ eager: true }`; each selected service is configured for local development with every endpoint on an automatic port, and `test.services` holds a typed handle per selected kind. Select a kind by name, or pass `{ service, config, endpoints }` to override part of its configuration. Disposal destroys the stack, then closes the client and removes the temporary project root. A session stack is also destroyed when the test process exits. + +Every test stack for the current OS user shares one state root and the package's artifact cache root under the OS temp directory, so their ports are coordinated and Docker uses one data volume; each gets a unique temporary project root and name. Pass `stateRoot`, `cacheRoot`, or `projectRoot` to override them. The runtime comes from `runtime`, then `SUPABASE_STACK_TEST_RUNTIME`, then the platform default: native where the catalog publishes native artifacts and Docker elsewhere. A startup failure names the state of each registered service and the owner log. + +`checkpoint(name)` stops the composition, saves the database data as an instance-scoped snapshot, and starts the composition again. `reset(name)` stops it, clears the database data, restores that snapshot, and waits until every service is ready. Checkpoints are never evicted by other stacks' snapshots and are removed when the test stack is destroyed. + +Effect tests use the scoped `makeTestStack`, which destroys the stack when its scope closes: + +```ts +import { makeTestStack } from "@supabase/stack/testing"; + +const test = Effect.gen(function* () { + const { services } = yield* makeTestStack({ services: ["database"] }); + yield* exercise(services.database); +}).pipe(Effect.scoped); +``` ## Effect consumers @@ -198,16 +216,8 @@ await Effect.runPromise( ); ``` -Cancelling an admitted lifecycle caller ends its wait; the owner finishes the operation. Cancelling an attached command ends that job and cleans up its resources. Command input and output stream with backpressure; the result contains a job ID and exit code, not collected output. Promise output sinks should return a Promise when the destination requires waiting for capacity. +The Promise entrypoint is derived from this one: each Effect becomes a call that accepts `{ signal }`, each Effect-returning function takes the same arguments plus a trailing `{ signal }` (a `signal` inside any other options object, such as command or composition options, is ignored), each Stream becomes an async iterable, and `Redacted` configuration values become plain strings. `commands.run` takes Promise-returning sinks and an async-iterable `stdin`. -For disposable Effect test fixtures, `acquireUseRelease` runs teardown on failure and interruption while retaining typed cleanup errors: - -```ts -const test = Effect.acquireUseRelease( - Stack.create(options), - (stack) => exerciseStack(stack), - (stack) => stack.destroy, -); -``` +Cancelling an admitted lifecycle caller ends its wait; the owner finishes the operation. Cancelling an attached command ends that job and cleans up its resources. Command input and output stream with backpressure; the result contains a job ID and exit code, not collected output. Promise command sinks should return a Promise when the destination requires waiting for capacity. The owner supports normal stop/start persistence. When an owner dies unexpectedly, its native processes die with it. Its containers remain until the next owner start in the same `stateRoot` removes them; that start also destroys session stacks whose owner is gone. Unexpected owner death does not trigger resource adoption or interrupted-operation recovery. CLI integration is maintained separately from this package. diff --git a/packages/stack/package.json b/packages/stack/package.json index 01df1442b9..c93c0764a7 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./effect": "./src/effect.ts", "./defaults": "./src/Defaults.ts", + "./testing": "./src/testing.ts", "./commands": "./src/Commands.ts", "./internal/dispatch": "./src/internal/dispatch.ts", "./internal/artifacts": "./src/internal/artifacts.ts", diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index d2f8c1ed1c..1637340042 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -151,6 +151,14 @@ const targetForPlatform = (platform: { return undefined; }; +/** Selects native execution where the catalog publishes native artifacts, and Docker elsewhere. */ +export const defaultRuntime = ( + platform: { readonly os: string; readonly arch: string } = { + os: process.platform, + arch: process.arch, + }, +): "native" | "docker" => (targetForPlatform(platform) === undefined ? "docker" : "native"); + const platformText = (platform: { readonly os: string; readonly arch: string }): string => `${platform.os}/${platform.arch}`; diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index e5957f3769..029119d629 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -142,7 +142,7 @@ it.live("forwards and rotates saved identity across composed services in one own endpoints: { http: { port: "auto" as const } }, }, ]; - const stackKeys = (suffix: string, gotrueJwtKeys: string): State.StackIdentityInput => ({ + const stackKeys = (suffix: string, gotrueJwtKeys: string): State.StackKeysInput => ({ publishableKey: `publishable-${suffix}`, secretKey: `secret-${suffix}`, anonKey: `anon-${suffix}`, diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index 87513c94b1..7dcfd4cf47 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -64,7 +64,7 @@ import * as Container from "./runtime/Container.ts"; import { projectSegmentFor } from "./identity/Identity.ts"; import { stackError, type OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; -import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; +import type { SavedStack, StackCredentials, StackKeysInput } from "./State.ts"; import { makeDockerHelperRegistry } from "./storage/DockerHelperRegistry.ts"; export interface OwnerOptions { @@ -259,7 +259,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { const resolveStackCredentials = Effect.fn("Owner.resolveStackCredentials")(function* ( overrides: Effect.Success>, - keys?: StackIdentityInput, + keys?: StackKeysInput, ) { const credentials = yield* options.state.withLock( Effect.gen(function* () { @@ -635,13 +635,13 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { ), rpcError("credentials"), ), - saveSnapshot: ({ id, key }) => + saveSnapshot: ({ id, key, scope = "cache" }) => databaseData(id, ({ saveDatabaseSnapshot: save }) => - save === undefined ? undefined : (context) => save(context, key), + save === undefined ? undefined : (context) => save(context, key, scope), ).pipe(rpcError("saveSnapshot")), - restoreSnapshot: ({ id, key }) => + restoreSnapshot: ({ id, key, scope = "cache" }) => databaseData(id, ({ restoreDatabaseSnapshot: restore }) => - restore === undefined ? undefined : (context) => restore(context, key), + restore === undefined ? undefined : (context) => restore(context, key, scope), ).pipe(rpcError("restoreSnapshot")), resetData: ({ id }) => databaseData(id, ({ resetDatabaseData }) => resetDatabaseData).pipe(rpcError("resetData")), diff --git a/packages/stack/src/PromiseClient.ts b/packages/stack/src/PromiseClient.ts new file mode 100644 index 0000000000..e834f6cb37 --- /dev/null +++ b/packages/stack/src/PromiseClient.ts @@ -0,0 +1,432 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { + Cause, + Effect, + Exit, + Layer, + ManagedRuntime, + Redacted, + Schema, + Scope, + Stream, +} from "effect"; +import type * as StackEffect from "./effect.ts"; +import { failureMessage } from "./internal/failure-message.ts"; +import { StackError } from "./Rpc.ts"; +import { ServiceCreationInput as CreationSchema } from "./services/Catalog.ts"; +import type { InitializationCommand, PgProveOptions, PostgresCommand } from "./Commands.ts"; + +/** Optional cancellation ends the caller's wait, or its attached command job. */ +export interface CallOptions { + readonly signal?: AbortSignal; +} + +/** Replaces `Redacted` secrets with their plain values. */ +export type Plain = + T extends Redacted.Redacted + ? A + : T extends (...args: never) => unknown + ? T + : T extends object + ? { [K in keyof T]: Plain } + : T; +type PlainArguments

> = { [K in keyof P]: Plain }; + +type Effectful = Effect.Effect; +/** + * The Promise form of an Effect handle: an Effect becomes a call, an Effect-returning function + * gains trailing call options and plain inputs, and a Stream becomes an async iterable. Results + * are data; operations that return handles are adapted explicitly. + */ +export type Promised = T extends Effectful + ? (options?: CallOptions) => Promise> + : T extends Stream.Stream + ? () => AsyncIterable + : T extends (...args: infer P) => infer R + ? R extends Effectful + ? (...args: [...PlainArguments

, callOptions?: CallOptions]) => Promise> + : T + : T extends object + ? { readonly [K in keyof T]: Promised } + : T; + +type Kind = StackEffect.ServiceCreationInput["service"]; +type Flatten = { [P in keyof T]: T[P] }; +/** Handles by kind: always present for a required kind, possibly absent for an optional one. */ +export type ServiceHandles< + Required extends Kind, + Optional extends Kind, + Instances extends { readonly [P in Kind]: unknown }, +> = Flatten< + { readonly [P in Required]: Instances[P] } & { + readonly [P in Exclude]?: Instances[P]; + } +>; +/** Plain service configuration accepted by non-Effect callers. */ +export type ServiceCreationInput = Plain; +/** A database instance with stopped-data snapshot operations. */ +export interface DatabaseInstance extends Promised {} +/** Maps creation discriminators to their supported instance operations. */ +export type ServiceInstances = { + readonly [K in Kind]: K extends "database" + ? DatabaseInstance + : Promised; +}; +/** An individual service; closing the client does not stop this process. */ +export type ServiceInstance = ServiceInstances[K]; +/** Streaming inputs and awaited output sinks for a PostgreSQL command. */ +export interface PostgresCommandOptions { + readonly args?: ReadonlyArray; + readonly env?: Readonly>; + readonly pgProve?: PgProveOptions; + readonly stdin?: AsyncIterable; + readonly stdout: (bytes: Uint8Array) => void | Promise; + readonly stderr: (bytes: Uint8Array) => void | Promise; +} +/** Output sinks for a finite service initialization command. */ +export interface InitializationCommandOptions { + readonly stdout?: (bytes: Uint8Array) => void | Promise; + readonly stderr?: (bytes: Uint8Array) => void | Promise; +} +type CommandOptions = Partial; +type CommandResult = Effect.Success>; +/** Runs a finite PostgreSQL or service initialization command. */ +interface CommandRunner { + ( + command: PostgresCommand, + options: PostgresCommandOptions, + callOptions?: CallOptions, + ): Promise; + ( + command: InitializationCommand, + options?: InitializationCommandOptions, + callOptions?: CallOptions, + ): Promise; +} +type DerivedStack = Promised; +/** A Promise client; closing the creating client of a session stack destroys the stack. */ +export interface Stack extends Omit { + readonly services: { + readonly create: ( + creation: Input, + options?: CallOptions, + ) => Promise; + readonly get: ( + ...args: Parameters + ) => Promise; + readonly list: (options?: CallOptions) => Promise>; + }; + readonly composition: Omit & { + readonly supabase: ( + ...args: Parameters + ) => Promise>; + }; + readonly commands: Omit & { readonly run: CommandRunner }; + /** Disposes this client and ends its active observation iterators. */ + readonly close: () => Promise; +} + +const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +/** Every failure and defect of a cause, so none is hidden behind the first. */ +const causeErrors = (cause: Cause.Cause): ReadonlyArray => + cause.reasons.flatMap((reason) => + Cause.isFailReason(reason) ? [reason.error] : Cause.isDieReason(reason) ? [reason.defect] : [], + ); +type ClientServices = Layer.Success; + +/** Runs Effects for one Promise client and owns the scope its handles live in. */ +export interface Client { + readonly run: ( + effect: Effect.Effect, + options?: CallOptions, + ) => Promise; + readonly iterable: (stream: Stream.Stream) => AsyncIterable; + readonly close: () => Promise; +} + +const makeClient = ( + runtime: ManagedRuntime.ManagedRuntime, + scope: Scope.Closeable, +): Client => { + const activeIterators = new Set<() => Promise>(); + let clientClosePromise: Promise | undefined; + const iterable = (stream: Stream.Stream): AsyncIterable => ({ + [Symbol.asyncIterator]() { + if (clientClosePromise !== undefined) throw new Error("Stack client is closed"); + const iterator = runtime + .runSync(Stream.toAsyncIterableEffect(stream)) + [Symbol.asyncIterator](); + const iteratorReturn = iterator.return; + const iteratorThrow = iterator.throw; + let iteratorClosePromise: Promise> | undefined; + let dispose: () => Promise; + const close = (): Promise> => { + if (iteratorClosePromise !== undefined) return iteratorClosePromise; + const promise: Promise> = ( + iteratorReturn === undefined + ? Promise.resolve>({ done: true, value: undefined }) + : iteratorReturn(undefined) + ).finally(() => activeIterators.delete(dispose)); + iteratorClosePromise = promise; + return promise; + }; + dispose = () => close().then(() => undefined); + activeIterators.add(dispose); + const settle = (result: Promise>) => + result.then( + (next) => { + if (next.done) activeIterators.delete(dispose); + return next; + }, + (error: unknown) => { + activeIterators.delete(dispose); + return Promise.reject(error); + }, + ); + return { + next: (value?: unknown) => settle(iterator.next(value)), + return: close, + ...(iteratorThrow === undefined + ? {} + : { throw: (error?: unknown) => settle(iteratorThrow(error)) }), + }; + }, + }); + const failures: Array = []; + const record = (error: unknown) => { + failures.push(error); + }; + return { + run: (effect, options) => runtime.runPromise(effect, options), + iterable, + close: () => { + if (clientClosePromise !== undefined) return clientClosePromise; + clientClosePromise = Promise.allSettled([...activeIterators].map((dispose) => dispose())) + .then((iterators) => { + for (const result of iterators) if (result.status === "rejected") record(result.reason); + return runtime.runPromiseExit(Scope.close(scope, Exit.void)); + }) + .then((exit) => { + if (Exit.isFailure(exit)) failures.push(...causeErrors(exit.cause)); + }) + .then(() => runtime.dispose()) + .catch(record) + .then(() => { + if (failures.length === 1) return Promise.reject(failures[0]); + if (failures.length > 1) + return Promise.reject(new AggregateError(failures, "Stack client close failed")); + }); + return clientClosePromise; + }, + }; +}; + +/** Runs a scoped acquisition whose handles stay usable until the returned client closes. */ +export const acquire = ( + effect: Effect.Effect, + options?: CallOptions, +): Promise<{ readonly value: A; readonly client: Client }> => { + const runtime = ManagedRuntime.make(clientLayer); + const scope = runtime.runSync(Scope.make()); + const client = makeClient(runtime, scope); + return runtime.runPromise(effect.pipe(Scope.provide(scope)), options).then( + (value) => ({ value, client }), + (error: unknown) => + client.close().then( + () => Promise.reject(error), + (closeError: unknown) => + Promise.reject( + new AggregateError([error, closeError], "Stack acquisition and its cleanup failed"), + ), + ), + ); +}; + +/** Runs one unscoped operation with the client services. */ +export const runOnce = ( + effect: Effect.Effect, + options?: CallOptions, +): Promise => Effect.runPromise(effect.pipe(Effect.provide(clientLayer)), options); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); +const isFunction = (value: unknown): value is (...args: ReadonlyArray) => unknown => + typeof value === "function"; +// Handle operations and streams need no services and fail with `StackError`. +const isOperation = (value: unknown): value is Effect.Effect => + Effect.isEffect(value); +const isStream = (value: unknown): value is Stream.Stream => + Stream.isStream(value); +/** No Effect operation takes a signal-only object, so one in last position is call options. */ +const isCallOptions = (value: unknown): value is CallOptions => + isRecord(value) && Object.keys(value).every((key) => key === "signal"); + +/** Adapts the operations of an Effect handle; their results pass through as data. */ +const makeAdapter = (client: Client) => { + const call = (result: unknown, options: CallOptions | undefined) => + isOperation(result) ? client.run(result, options) : result; + function promised(value: T): Promised; + function promised(value: unknown): unknown { + if (isStream(value)) return () => client.iterable(value); + if (isOperation(value)) return (options?: CallOptions) => call(value, options); + if (isFunction(value)) + return (...args: ReadonlyArray) => { + const last = args.at(-1); + return isCallOptions(last) + ? call(value(...args.slice(0, -1)), last) + : call(value(...args), undefined); + }; + if (isRecord(value)) + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, promised(item)])); + return value; + } + return promised; +}; + +const creationJson = Schema.toCodecJson(CreationSchema); +const decodeCreation = (operation: string, creation: unknown) => + Schema.decodeUnknownEffect(creationJson)(creation).pipe( + Effect.mapError((cause) => new StackError({ operation, message: cause.message })), + ); +const decodeCreations = (operation: string, creations: ReadonlyArray) => + Effect.forEach(creations, (creation) => decodeCreation(operation, creation)); +const sinkError = (cause: unknown) => + new StackError({ + operation: "command-stream", + message: failureMessage(cause), + }); +const sink = (write?: (bytes: Uint8Array) => void | Promise) => (bytes: Uint8Array) => + write === undefined + ? Effect.void + : Effect.tryPromise({ try: () => Promise.resolve(write(bytes)), catch: sinkError }); + +/** Builds the Promise forms of a client's stack and service handles. */ +export const stackAdapter = (client: Client) => { + const promised = makeAdapter(client); + function instance(service: StackEffect.ServiceInstances[K]): ServiceInstances[K]; + function instance(service: { + readonly service: Kind; + readonly restart: (input?: StackEffect.ServiceCreationInput) => Effect.Effect; + }): unknown { + return { + ...promised(service), + restart: (input?: { readonly config: unknown }, options?: CallOptions) => + client.run( + input === undefined + ? service.restart() + : decodeCreation( + "restart", + "service" in input ? input : { service: service.service, config: input.config }, + ).pipe( + Effect.flatMap((creation) => + creation.service === service.service + ? service.restart(creation) + : Effect.fail( + new StackError({ + operation: "restart", + message: "Service kind cannot change", + }), + ), + ), + ), + options, + ), + }; + } + function services( + handles: ServiceHandles, + ): ServiceHandles; + function services( + handles: Readonly>, + ): Readonly> { + return Object.fromEntries( + Object.entries(handles).map(([kind, handle]) => [kind, instance(handle)]), + ); + } + const stack = (handle: StackEffect.Stack): Stack => { + const derived = promised(handle); + const instances = (handles: ReadonlyArray) => + handles.map((service) => instance(service)); + function create( + creation: Input, + options?: CallOptions, + ): Promise; + function create(creation: ServiceCreationInput, options?: CallOptions): Promise { + return client.run( + decodeCreation("createService", creation).pipe( + Effect.flatMap(handle.services.create), + Effect.map((service) => instance(service)), + ), + options, + ); + } + function run( + command: PostgresCommand, + commandOptions: PostgresCommandOptions, + options?: CallOptions, + ): Promise; + function run( + command: InitializationCommand, + commandOptions?: InitializationCommandOptions, + options?: CallOptions, + ): Promise; + function run( + command: PostgresCommand | InitializationCommand, + commandOptions?: CommandOptions, + options?: CallOptions, + ): Promise { + if ("type" in command) + return client.run( + handle.commands.run(command, { + stdout: sink(commandOptions?.stdout), + stderr: sink(commandOptions?.stderr), + }), + options, + ); + return client.run( + handle.commands.run(command, { + args: commandOptions?.args, + env: commandOptions?.env, + pgProve: commandOptions?.pgProve, + ...(commandOptions?.stdin === undefined + ? {} + : { stdin: Stream.fromAsyncIterable(commandOptions.stdin, sinkError) }), + stdout: sink(commandOptions?.stdout), + stderr: sink(commandOptions?.stderr), + }), + options, + ); + } + return { + ...derived, + services: { + create, + get: (id, options) => + client.run( + Effect.map(handle.services.get(id), (service) => instance(service)), + options, + ), + list: (options) => client.run(Effect.map(handle.services.list, instances), options), + }, + composition: { + ...derived.composition, + supabase: (creations, compositionOptions, options) => + client.run( + decodeCreations("supabaseComposition", creations).pipe( + Effect.flatMap((decoded) => handle.composition.supabase(decoded, compositionOptions)), + Effect.map(instances), + ), + options, + ), + plan: (creations, options) => + client.run( + decodeCreations("plan", creations).pipe(Effect.flatMap(handle.composition.plan)), + options, + ), + }, + commands: { ...derived.commands, run }, + close: client.close, + }; + }; + return { stack, services }; +}; diff --git a/packages/stack/src/PromiseClient.unit.test.ts b/packages/stack/src/PromiseClient.unit.test.ts new file mode 100644 index 0000000000..a8055027d5 --- /dev/null +++ b/packages/stack/src/PromiseClient.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { acquire } from "./PromiseClient.ts"; +import { StackError } from "./Rpc.ts"; + +const failingFinalizers = Effect.addFinalizer(() => Effect.die(new Error("first finalizer"))).pipe( + Effect.andThen(Effect.addFinalizer(() => Effect.die(new Error("second finalizer")))), +); +const messages = (error: unknown): ReadonlyArray => + error instanceof AggregateError + ? error.errors.flatMap(messages) + : [error instanceof Error ? error.message : String(error)]; + +describe("Promise client cleanup", () => { + it.effect("reports every finalizer failure when a client closes", () => + Effect.gen(function* () { + const { client } = yield* Effect.promise(() => acquire(failingFinalizers)); + + const failure = yield* Effect.promise(() => + client.close().then( + () => undefined, + (error: unknown) => error, + ), + ); + + expect(failure).toBeInstanceOf(AggregateError); + expect([...messages(failure)].sort()).toEqual(["first finalizer", "second finalizer"]); + }), + ); + + it.effect("keeps the acquisition failure and its cleanup failures together", () => + Effect.gen(function* () { + const failure = yield* Effect.promise(() => + acquire( + failingFinalizers.pipe( + Effect.andThen( + Effect.fail(new StackError({ operation: "create", message: "refused" })), + ), + ), + ).then( + () => undefined, + (error: unknown) => error, + ), + ); + + expect(failure).toBeInstanceOf(AggregateError); + expect(failure instanceof AggregateError ? failure.errors[0] : undefined).toBeInstanceOf( + StackError, + ); + expect([...messages(failure)].sort()).toEqual([ + "first finalizer", + "refused", + "second finalizer", + ]); + }), + ); +}); diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 593c017579..6e851424af 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -1,9 +1,10 @@ import { Exit, Schema } from "effect"; import { Rpc, RpcGroup } from "effect/unstable/rpc"; import { ServiceCreation, ServiceCreationInput } from "./services/Catalog.ts"; +import { snapshotScopes } from "./services/DatabaseSnapshot.ts"; import { causeMessage, CompositionConfig, OrchestratorError } from "./Orchestrator.ts"; import { CommandInvocation } from "./Commands.ts"; -import { StackIdentityInput } from "./State.ts"; +import { StackKeysInput } from "./State.ts"; import { failureMessage } from "./internal/failure-message.ts"; const Outcome = Schema.Struct({ @@ -75,6 +76,7 @@ export const Definition = Schema.Struct({ id: Schema.String, creation: ServiceCr export interface Definition extends Schema.Schema.Type {} const Instance = { id: Schema.String }; +const SnapshotScope = Schema.Literals(snapshotScopes); const Log = Schema.Struct({ stream: Schema.Literals(["stdout", "stderr"]), bytes: Schema.Uint8ArrayFromBase64, @@ -122,11 +124,11 @@ export const OwnerRpc = RpcGroup.make( error: StackError, }), Rpc.make("saveSnapshot", { - payload: { ...Instance, key: Schema.String }, + payload: { ...Instance, key: Schema.String, scope: Schema.optionalKey(SnapshotScope) }, error: StackError, }), Rpc.make("restoreSnapshot", { - payload: { ...Instance, key: Schema.String }, + payload: { ...Instance, key: Schema.String, scope: Schema.optionalKey(SnapshotScope) }, success: Schema.Boolean, error: StackError, }), @@ -135,7 +137,7 @@ export const OwnerRpc = RpcGroup.make( payload: { services: Schema.Array(ServiceCreationInput), reuseIds: Schema.optionalKey(Schema.Array(Schema.String)), - keys: Schema.optionalKey(StackIdentityInput), + keys: Schema.optionalKey(StackKeysInput), eager: Schema.optionalKey(Schema.Boolean), }, success: Schema.Array(Definition), diff --git a/packages/stack/src/State.ts b/packages/stack/src/State.ts index 440e864641..7b738ac908 100644 --- a/packages/stack/src/State.ts +++ b/packages/stack/src/State.ts @@ -36,7 +36,8 @@ const SavedInstance = Schema.Struct({ }); interface SavedInstance extends Schema.Schema.Type {} -export const StackIdentityInput = Schema.Struct({ +/** API and JWT signing keys that override the keys a stack derives by default. */ +export const StackKeysInput = Schema.Struct({ publishableKey: Schema.optionalKey(Schema.String), secretKey: Schema.optionalKey(Schema.String), anonKey: Schema.optionalKey(Schema.String), @@ -47,7 +48,7 @@ export const StackIdentityInput = Schema.Struct({ publicSigningKeys: Schema.optionalKey(Schema.String), remoteJwks: Schema.optionalKey(Schema.String), }); -export interface StackIdentityInput extends Schema.Schema.Type {} +export interface StackKeysInput extends Schema.Schema.Type {} export const StackCredentials = Schema.Struct({ jwtSecret: Schema.String, diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index c5f5446b48..74a436099e 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -3,7 +3,7 @@ import { postgresVersion } from "../Artifacts.ts"; import { causeMessage, type CompositionConfig } from "../Orchestrator.ts"; import type { Observation } from "../Rpc.ts"; import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; -import type { SavedStack, StackIdentityInput } from "../State.ts"; +import type { SavedStack, StackKeysInput } from "../State.ts"; import { credentialInputNames } from "../host/Credentials.ts"; import { apiRoute, endpointNames, endpointPort } from "../host/Endpoints.ts"; @@ -202,7 +202,7 @@ export interface SupabaseCompositionOperations { export interface SupabaseCompositionOptions { /** Reuses stopped instances; inputs declare desired bindings, not previous resolved creations. */ readonly reuseIds?: ReadonlyArray; - readonly keys?: StackIdentityInput; + readonly keys?: StackKeysInput; /** * Starts every member with the composition. By default the database and members without an * endpoint start eagerly, and other members start on their first connection. diff --git a/packages/stack/src/defaults.integration.test.ts b/packages/stack/src/defaults.integration.test.ts index 822b35604d..af71143ea4 100644 --- a/packages/stack/src/defaults.integration.test.ts +++ b/packages/stack/src/defaults.integration.test.ts @@ -2,17 +2,9 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Redacted } from "effect"; import { tmpdir } from "node:os"; -import { - create as createEffect, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_POSTGRES_ROOT_KEY, - StackError, -} from "./effect.ts"; -import { - create as createPromise, - DEFAULT_LOCAL_JWT_SECRET as PROMISE_DEFAULT_JWT_SECRET, - DEFAULT_POSTGRES_ROOT_KEY as PROMISE_DEFAULT_ROOT_KEY, -} from "./index.ts"; +import { DEFAULT_LOCAL_JWT_SECRET, DEFAULT_POSTGRES_ROOT_KEY } from "./Defaults.ts"; +import { create as createEffect, StackError } from "./effect.ts"; +import { create as createPromise } from "./index.ts"; const artifactCacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const effectLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); @@ -134,10 +126,8 @@ describe("database configuration defaults", { timeout: 180_000 }, () => { expect(initial.config.service).toBe("database"); if (initial.config.service !== "database") return yield* Effect.die("Expected a database status"); - expect(redactedValue(initial.config.config.jwtSecret)).toBe( - PROMISE_DEFAULT_JWT_SECRET, - ); - expect(redactedValue(initial.config.config.rootKey)).toBe(PROMISE_DEFAULT_ROOT_KEY); + expect(redactedValue(initial.config.config.jwtSecret)).toBe(DEFAULT_LOCAL_JWT_SECRET); + expect(redactedValue(initial.config.config.rootKey)).toBe(DEFAULT_POSTGRES_ROOT_KEY); yield* promise(() => database.restart({ config: promiseDatabaseConfig })); yield* promise(() => database.ready()); @@ -146,9 +136,11 @@ describe("database configuration defaults", { timeout: 180_000 }, () => { if (restarted.config.service !== "database") return yield* Effect.die("Expected a restarted database status"); expect(redactedValue(restarted.config.config.jwtSecret)).toBe( - PROMISE_DEFAULT_JWT_SECRET, + DEFAULT_LOCAL_JWT_SECRET, + ); + expect(redactedValue(restarted.config.config.rootKey)).toBe( + DEFAULT_POSTGRES_ROOT_KEY, ); - expect(redactedValue(restarted.config.config.rootKey)).toBe(PROMISE_DEFAULT_ROOT_KEY); const conflict = yield* Effect.flip( promise(() => @@ -173,10 +165,10 @@ describe("database configuration defaults", { timeout: 180_000 }, () => { if (composedStatus.config.service !== "database") return yield* Effect.die("Expected a composed database status"); expect(redactedValue(composedStatus.config.config.jwtSecret)).toBe( - PROMISE_DEFAULT_JWT_SECRET, + DEFAULT_LOCAL_JWT_SECRET, ); expect(redactedValue(composedStatus.config.config.rootKey)).toBe( - PROMISE_DEFAULT_ROOT_KEY, + DEFAULT_POSTGRES_ROOT_KEY, ); }), (current) => diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 935733d3b9..c9642732f7 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -42,7 +42,7 @@ import { volumeDataCleanupCommands } from "./storage/DockerDatabaseStorage.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; import { failureMessage } from "./internal/failure-message.ts"; import * as State from "./State.ts"; -import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; +import type { SavedStack, StackCredentials, StackKeysInput } from "./State.ts"; import { StackError, type Definition, type Observation } from "./Rpc.ts"; import { reclaimStack } from "./Sweep.ts"; import { @@ -50,6 +50,7 @@ import { type ServiceCreation, type ServiceCreationInput as CatalogServiceCreationInput, } from "./services/Catalog.ts"; +import type { SnapshotScope } from "./services/DatabaseSnapshot.ts"; import * as Orchestrator from "./Orchestrator.ts"; import type { InitializationCommand, @@ -57,25 +58,12 @@ import type { PostgresCommand, CommandInvocation, } from "./Commands.ts"; -export { - DEFAULT_LOCAL_DATABASE_PASSWORD, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_LOCAL_PUBLISHABLE_KEY, - DEFAULT_LOCAL_S3_ACCESS_KEY_ID, - DEFAULT_LOCAL_S3_REGION, - DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, - DEFAULT_LOCAL_SECRET_KEY, - DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, - DEFAULT_POOLER_VAULT_ENCRYPTION_KEY, - DEFAULT_POSTGRES_ROOT_KEY, - DEFAULT_REALTIME_DB_ENCRYPTION_KEY, - DEFAULT_SIGNING_KEY, -} from "./Defaults.ts"; export { initialization, postgres } from "./Commands.ts"; export { resolveNativePostgresUser } from "./runtime/postgres-user.ts"; export { StackError } from "./Rpc.ts"; export type { ServiceCreation } from "./services/Catalog.ts"; +/** A service creation as `services.create` accepts it, before stack credentials fill its inputs. */ export type ServiceCreationInput = CatalogServiceCreationInput; export type { CompositionConfig } from "./Orchestrator.ts"; export type { @@ -85,7 +73,7 @@ export type { } from "./composition/Supabase.ts"; export { StackIdSchema as StackId } from "./identity/StackId.ts"; export type { SavedStack } from "./State.ts"; -export type { StackCredentials, StackIdentityInput }; +export type { StackCredentials, StackKeysInput }; export type { Observation } from "./Rpc.ts"; export type { Command, @@ -168,10 +156,24 @@ export interface ServiceInstance { readonly from?: "host" | "runtime"; }) => Effect.Effect>, StackError>; } +/** Snapshot placement; `cache` is the default. */ +export interface DatabaseSnapshotOptions { + /** + * `cache` shares bounded retention with every stack under the cache root and outlives the + * instance; `instance` is never evicted and is removed when the database instance is destroyed. + */ + readonly scope?: SnapshotScope; +} /** A database instance with stopped-data snapshot operations. */ export interface DatabaseInstance extends ServiceInstance<"database"> { - readonly saveSnapshot: (key: string) => Effect.Effect; - readonly restoreSnapshot: (key: string) => Effect.Effect; + readonly saveSnapshot: ( + key: string, + options?: DatabaseSnapshotOptions, + ) => Effect.Effect; + readonly restoreSnapshot: ( + key: string, + options?: DatabaseSnapshotOptions, + ) => Effect.Effect; /** Removes database-owned data while preserving the instance registration. */ readonly resetData: Effect.Effect; } @@ -615,6 +617,8 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( return { runtimeCleanup: "complete" } as const; }); + const snapshotScope = (options: DatabaseSnapshotOptions | undefined) => + options?.scope === undefined ? {} : { scope: options.scope }; const common = (id: string, service: K): ServiceInstance => ({ id, service, @@ -650,9 +654,12 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( case "database": return { ...common(id, "database"), - saveSnapshot: (key) => call("saveSnapshot", (rpc) => rpc.saveSnapshot({ id, key })), - restoreSnapshot: (key) => - call("restoreSnapshot", (rpc) => rpc.restoreSnapshot({ id, key })), + saveSnapshot: (key, options) => + call("saveSnapshot", (rpc) => rpc.saveSnapshot({ id, key, ...snapshotScope(options) })), + restoreSnapshot: (key, options) => + call("restoreSnapshot", (rpc) => + rpc.restoreSnapshot({ id, key, ...snapshotScope(options) }), + ), resetData: call("resetData", (rpc) => rpc.resetData({ id })), }; case "rest": diff --git a/packages/stack/src/host/Credentials.ts b/packages/stack/src/host/Credentials.ts index 3172202853..64705e0452 100644 --- a/packages/stack/src/host/Credentials.ts +++ b/packages/stack/src/host/Credentials.ts @@ -6,7 +6,7 @@ import { } from "../Defaults.ts"; import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; import { resolveStackKeys } from "../services/ServiceConfig.ts"; -import type { SavedStack, StackCredentials, StackIdentityInput } from "../State.ts"; +import type { SavedStack, StackCredentials, StackKeysInput } from "../State.ts"; export class CredentialError extends Data.TaggedError("CredentialError")<{ readonly message: string; @@ -101,7 +101,7 @@ const credentialsChanged = (saved: StackCredentials, next: StackCredentials) => export const nextCredentials = Effect.fn("Credentials.next")(function* ( current: Pick, overrides: Overrides, - keys: StackIdentityInput | undefined, + keys: StackKeysInput | undefined, ) { const saved = current.credentials; if (saved === undefined) { diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 0ed668c651..c1df2cbe77 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,422 +1,64 @@ -import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect"; +import { Effect, Option } from "effect"; import * as StackEffect from "./effect.ts"; -import { StackError } from "./Rpc.ts"; -import { - ServiceCreationInput as CreationSchema, - type ServiceCreation as EffectCreation, -} from "./services/Catalog.ts"; -import type { InitializationCommand, PostgresCommand } from "./Commands.ts"; - -export { - DEFAULT_LOCAL_DATABASE_PASSWORD, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_LOCAL_PUBLISHABLE_KEY, - DEFAULT_LOCAL_S3_ACCESS_KEY_ID, - DEFAULT_LOCAL_S3_REGION, - DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, - DEFAULT_LOCAL_SECRET_KEY, - DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, - DEFAULT_POOLER_VAULT_ENCRYPTION_KEY, - DEFAULT_POSTGRES_ROOT_KEY, - DEFAULT_REALTIME_DB_ENCRYPTION_KEY, - DEFAULT_SIGNING_KEY, -} from "./Defaults.ts"; -export type { StackCredentials, StackIdentityInput } from "./State.ts"; +import { acquire, runOnce, stackAdapter, type CallOptions, type Client } from "./PromiseClient.ts"; +export type { + CallOptions, + DatabaseInstance, + ServiceCreationInput, + ServiceInstance, + ServiceInstances, + InitializationCommandOptions, + PostgresCommandOptions, + Stack, +} from "./PromiseClient.ts"; +export type { StackCredentials, StackKeysInput } from "./State.ts"; export { initialization, postgres } from "./Commands.ts"; export { StackError } from "./Rpc.ts"; -type DatabaseCreation = Extract; -/** Plain service configuration accepted by non-Effect callers. */ -export type ServiceCreation = - | Exclude - | (Omit & { - readonly config: Omit< - DatabaseCreation["config"], - "databasePassword" | "jwtSecret" | "rootKey" - > & { - readonly databasePassword?: string; - readonly jwtSecret?: string; - readonly rootKey?: string; - }; - }); -export type ServiceCreationInput = ServiceCreation; -const creationJson = Schema.toCodecJson(CreationSchema); -const decodeCreation = (creation: unknown) => - Schema.decodeUnknownEffect(creationJson)(creation).pipe( - Effect.mapError((cause) => new StackError({ operation: "config", message: cause.message })), - ); export type { CompositionConfig } from "./Orchestrator.ts"; export type { Observation } from "./Rpc.ts"; -export type { PgProveOptions } from "./effect.ts"; -export type { CreationChange, PlannedInstance, SupabaseCompositionOptions } from "./effect.ts"; export type { CreateOptions, + CreationChange, + DatabaseSnapshotOptions, DestroyResult, FindOptions, FoundStack, OpenOptions, + PgProveOptions, + PlannedInstance, + SavedStack, StackLocations, + SupabaseCompositionOptions, } from "./effect.ts"; -const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); -type Runtime = ReturnType; -const makeRuntime = () => ManagedRuntime.make(clientLayer); -type Kind = ServiceCreation["service"]; -/** Optional cancellation ends the caller's wait, or its attached command job. */ -export interface CallOptions { - readonly signal?: AbortSignal; -} -export type CompositionSupabaseOptions = StackEffect.SupabaseCompositionOptions & CallOptions; -/** An individual service; closing the client does not stop this process. */ -export interface ServiceInstance { - readonly id: string; - readonly service: K; - readonly start: (options?: CallOptions) => Promise; - readonly ready: (options?: CallOptions) => Promise; - readonly stop: (options?: CallOptions) => Promise; - readonly restart: ( - input?: Pick, "config">, - options?: CallOptions, - ) => Promise; - readonly destroy: (options?: CallOptions) => Promise; - /** Ensures the service artifact or image is available without starting the service. */ - readonly prepare: (options?: CallOptions) => Promise; - readonly status: (options?: CallOptions) => Promise; - readonly followStatus: () => AsyncIterable; - readonly logs: () => AsyncIterable<{ - readonly stream: "stdout" | "stderr"; - readonly bytes: Uint8Array; - }>; - readonly credentials: ( - options?: CallOptions & { readonly from?: "host" | "runtime" }, - ) => Promise>>; -} -/** Database storage operations require a stopped instance with wake disabled. */ -export interface DatabaseInstance extends ServiceInstance<"database"> { - readonly saveSnapshot: (key: string, options?: CallOptions) => Promise; - readonly restoreSnapshot: (key: string, options?: CallOptions) => Promise; - /** Removes database-owned data while preserving the instance registration. */ - readonly resetData: (options?: CallOptions) => Promise; -} -/** Creation preserves the selected service's available operations. */ -export type ServiceInstances = { - [K in Kind]: K extends "database" ? DatabaseInstance : ServiceInstance; -}; -type AnyInstance = ServiceInstances[Kind]; -/** Streaming inputs and awaited output sinks for a PostgreSQL command. */ -export interface PostgresCommandOptions extends CallOptions { - readonly args?: ReadonlyArray; - readonly env?: Readonly>; - readonly pgProve?: StackEffect.PgProveOptions; - readonly stdin?: AsyncIterable; - readonly stdout: (bytes: Uint8Array) => void | Promise; - readonly stderr: (bytes: Uint8Array) => void | Promise; -} -/** Output sinks for a finite service initialization command. */ -export interface InitializationCommandOptions extends CallOptions { - readonly stdout?: (bytes: Uint8Array) => void | Promise; - readonly stderr?: (bytes: Uint8Array) => void | Promise; -} -interface InternalCommandOptions extends CallOptions { - readonly args?: ReadonlyArray; - readonly env?: Readonly>; - readonly pgProve?: StackEffect.PgProveOptions; - readonly stdin?: AsyncIterable; - readonly stdout?: (bytes: Uint8Array) => void | Promise; - readonly stderr?: (bytes: Uint8Array) => void | Promise; -} - -const adapt = (handle: StackEffect.Stack, runtime: Runtime, scope: Scope.Closeable) => { - const run = (effect: Effect.Effect, options?: CallOptions) => - runtime.runPromise(effect, options); - const activeIterators = new Set<() => Promise>(); - let clientClosePromise: Promise | undefined; - const iterable = (stream: Stream.Stream): AsyncIterable => ({ - [Symbol.asyncIterator]() { - if (clientClosePromise !== undefined) throw new Error("Stack client is closed"); - const iterator = runtime - .runSync(Stream.toAsyncIterableEffect(stream)) - [Symbol.asyncIterator](); - const iteratorReturn = iterator.return; - const iteratorThrow = iterator.throw; - let iteratorClosePromise: Promise> | undefined; - let dispose: () => Promise; - const close = (): Promise> => { - if (iteratorClosePromise !== undefined) return iteratorClosePromise; - const promise: Promise> = ( - iteratorReturn === undefined - ? Promise.resolve>({ done: true, value: undefined }) - : iteratorReturn(undefined) - ).finally(() => activeIterators.delete(dispose)); - iteratorClosePromise = promise; - return promise; - }; - dispose = () => close().then(() => undefined); - activeIterators.add(dispose); - return { - next: (value?: unknown) => - iterator.next(value).then( - (result) => { - if (result.done) { - activeIterators.delete(dispose); - } - return result; - }, - (error) => { - activeIterators.delete(dispose); - return Promise.reject(error); - }, - ), - return: close, - ...(iteratorThrow === undefined - ? {} - : { - throw: (error?: unknown) => - iteratorThrow(error).then( - (result) => { - if (result.done) { - activeIterators.delete(dispose); - } - return result; - }, - (failure) => { - activeIterators.delete(dispose); - return Promise.reject(failure); - }, - ), - }), - }; - }, - }); - const common = (service: StackEffect.ServiceInstance): ServiceInstance => ({ - id: service.id, - service: service.service, - start: (options) => run(service.start, options), - ready: (options) => run(service.ready, options), - stop: (options) => run(service.stop, options), - restart: (input, options) => - run( - input === undefined - ? service.restart() - : decodeCreation({ - service: service.service, - config: input.config, - }).pipe( - Effect.mapError( - (cause) => new StackError({ operation: "restart", message: cause.message }), - ), - Effect.flatMap((decoded) => { - const matchesKind = ( - candidate: StackEffect.ServiceCreationInput, - ): candidate is Extract => - candidate.service === service.service; - return matchesKind(decoded) - ? service.restart(decoded) - : Effect.fail( - new StackError({ - operation: "restart", - message: "Service kind cannot change", - }), - ); - }), - ), - options, - ), - destroy: (options) => run(service.destroy, options), - prepare: (options) => run(service.prepare, options), - status: (options) => run(service.status, options), - followStatus: () => iterable(service.followStatus), - logs: () => iterable(service.logs), - credentials: (options) => run(service.credentials(options), options), - }); - function instance(service: StackEffect.ServiceInstances[K]): ServiceInstances[K]; - function instance(service: StackEffect.ServiceInstances[Kind]): AnyInstance { - switch (service.service) { - case "database": - return { - ...common(service), - saveSnapshot: (key, options) => run(service.saveSnapshot(key), options), - restoreSnapshot: (key, options) => run(service.restoreSnapshot(key), options), - resetData: (options) => run(service.resetData, options), - }; - case "rest": - return common(service); - case "auth": - return common(service); - case "realtime": - return common(service); - case "storage": - return common(service); - case "imgproxy": - return common(service); - case "functions": - return common(service); - case "studio": - return common(service); - case "pgmeta": - return common(service); - case "mail": - return common(service); - case "analytics": - return common(service); - case "vector": - return common(service); - case "pooler": - return common(service); - } - } - function create( - creation: Input, - options?: CallOptions, - ): Promise; - function create(creation: ServiceCreation, options?: CallOptions): Promise { - return run( - decodeCreation(creation).pipe(Effect.flatMap(handle.services.create), Effect.map(instance)), - options, - ); - } - const sinkError = (cause: unknown) => - new StackError({ - operation: "command-stream", - message: cause instanceof Error ? cause.message : String(cause), - }); - return { - id: handle.id, - services: { - create, - get: (id: string, options?: CallOptions) => - run(handle.services.get(id).pipe(Effect.map(instance)), options), - list: (options?: CallOptions) => - run(handle.services.list.pipe(Effect.map((services) => services.map(instance))), options), - }, - credentials: { - get: (options?: CallOptions) => run(handle.credentials.get, options), - }, - composition: { - supabase: (services: ReadonlyArray, options?: CompositionSupabaseOptions) => - run( - Effect.forEach(services, decodeCreation).pipe( - Effect.flatMap((decoded) => handle.composition.supabase(decoded, options)), - Effect.map((instances) => instances.map(instance)), - ), - options, - ), - plan: (services: ReadonlyArray, options?: CallOptions) => - run( - Effect.forEach(services, decodeCreation).pipe(Effect.flatMap(handle.composition.plan)), - options, - ), - configure: (config: StackEffect.CompositionConfig, options?: CallOptions) => - run(handle.composition.configure(config), options), - describe: (options?: CallOptions) => run(handle.composition.describe, options), - start: (options?: CallOptions) => run(handle.composition.start, options), - stop: (options?: CallOptions) => run(handle.composition.stop, options), - restart: (options?: CallOptions) => run(handle.composition.restart, options), - }, - stop: (options?: CallOptions) => run(handle.stop, options), - destroy: (options?: CallOptions) => run(handle.destroy, options), - close: () => { - if (clientClosePromise !== undefined) return clientClosePromise; - clientClosePromise = Promise.allSettled([...activeIterators].map((dispose) => dispose())) - .then(() => runtime.runPromise(Scope.close(scope, Exit.void))) - .then(() => runtime.dispose()); - return clientClosePromise; - }, - commands: { run: runCommands }, - }; - - function runCommands( - command: PostgresCommand, - options: PostgresCommandOptions, - ): Promise<{ - readonly jobId: string; - readonly exitCode: number; - }>; - function runCommands( - command: InitializationCommand, - options?: InitializationCommandOptions, - ): Promise<{ - readonly jobId: string; - readonly exitCode: number; - }>; - function runCommands( - command: PostgresCommand | InitializationCommand, - options?: InternalCommandOptions, - ) { - const output = (sink: InternalCommandOptions["stdout"]) => (bytes: Uint8Array) => - sink === undefined - ? Effect.void - : Effect.tryPromise({ try: () => Promise.resolve(sink(bytes)), catch: sinkError }); - if ("type" in command) - return run( - handle.commands.run(command, { - ...(options?.stdout === undefined ? {} : { stdout: output(options.stdout) }), - ...(options?.stderr === undefined ? {} : { stderr: output(options.stderr) }), - }), - options, - ); - return run( - handle.commands.run(command, { - args: options?.args, - env: options?.env, - pgProve: options?.pgProve, - ...(options?.stdin === undefined - ? {} - : { stdin: Stream.fromAsyncIterable(options.stdin, sinkError) }), - stdout: output(options?.stdout), - stderr: output(options?.stderr), - }), - options, - ); - } -}; -/** A Promise client; closing the creating client of a session stack destroys the stack. */ -export type Stack = ReturnType; +const adaptStack = (acquired: { readonly value: StackEffect.Stack; readonly client: Client }) => + stackAdapter(acquired.client).stack(acquired.value); -const acquire = ( - effect: ReturnType, - options?: CallOptions, -): Promise => { - const runtime = makeRuntime(); - const scope = runtime.runSync(Scope.make()); - return runtime.runPromise(effect.pipe(Scope.provide(scope)), options).then( - (handle) => adapt(handle, runtime, scope), - (error: unknown) => - runtime - .runPromise(Scope.close(scope, Exit.void)) - .then(() => runtime.dispose()) - .then(() => Promise.reject(error)), - ); -}; /** Registers a new stack identity. */ -export const create = ( - options: StackEffect.CreateOptions, - callOptions?: CallOptions, -): Promise => acquire(StackEffect.create(options), callOptions); +export const create = (options: StackEffect.CreateOptions, callOptions?: CallOptions) => + acquire(StackEffect.create(options), callOptions).then(adaptStack); /** Opens an existing stack without launching its services. */ -export const open = (options: StackEffect.OpenOptions, callOptions?: CallOptions): Promise => - acquire(StackEffect.open(options), callOptions); +export const open = (options: StackEffect.OpenOptions, callOptions?: CallOptions) => + acquire(StackEffect.open(options), callOptions).then(adaptStack); /** Discovers readable saved stacks with their live owners; `onInvalidState` observes skipped entries. */ export const discover = ( options: Pick & { readonly onInvalidState?: (id: string, error: Error) => void; }, + callOptions?: CallOptions, ) => { const onInvalidState = options.onInvalidState; - return Effect.runPromise( + return runOnce( StackEffect.discover({ stateRoot: options.stateRoot, ...(onInvalidState === undefined ? {} : { onInvalidState: (id, error) => Effect.sync(() => onInvalidState(id, error)) }), - }).pipe(Effect.provide(clientLayer)), + }), + callOptions, ); }; /** Reads one saved stack by id or by project identity; resolves `undefined` when none is saved. */ -export const find = ( - options: StackEffect.FindOptions, -): Promise => - Effect.runPromise( - StackEffect.find(options).pipe(Effect.map(Option.getOrUndefined), Effect.provide(clientLayer)), - ); +export const find = (options: StackEffect.FindOptions, callOptions?: CallOptions) => + runOnce(StackEffect.find(options).pipe(Effect.map(Option.getOrUndefined)), callOptions); diff --git a/packages/stack/src/internal/artifacts.ts b/packages/stack/src/internal/artifacts.ts index 4e73b13dcf..cce55dec8c 100644 --- a/packages/stack/src/internal/artifacts.ts +++ b/packages/stack/src/internal/artifacts.ts @@ -2,6 +2,7 @@ export { ArtifactError, artifactServiceKinds, + defaultRuntime, postgresVersion, prepareNativeArtifact, resolveArtifact, diff --git a/packages/stack/src/promise-api.integration.test.ts b/packages/stack/src/promise-api.integration.test.ts new file mode 100644 index 0000000000..d17f2f7792 --- /dev/null +++ b/packages/stack/src/promise-api.integration.test.ts @@ -0,0 +1,72 @@ +import { NodeServices, NodeSocketServer } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Redacted, Schema } from "effect"; +import { discover, StackError, type Observation } from "./index.ts"; +import { createTestStack } from "./testing.ts"; + +const databaseSecret = (observation: Observation) => + observation.config.service === "database" + ? observation.config.config.databasePassword + : undefined; + +it.live( + "returns observations as data, with exits and Redacted secrets intact", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-promise-state-" }); + const test = yield* Effect.acquireRelease( + Effect.promise(() => createTestStack({ runtime: "native", stateRoot })), + (created) => Effect.promise(() => created[Symbol.asyncDispose]()), + ); + const database = test.services.database; + + yield* Effect.promise(() => database.stop()); + const stopped = yield* Effect.promise(() => database.status()); + expect(Exit.isExit(stopped.exit)).toBe(true); + expect(Redacted.isRedacted(databaseSecret(stopped))).toBe(true); + + yield* Effect.promise(() => test.stack.composition.start()); + const members = yield* Effect.promise(() => test.stack.composition.stop()); + expect(members.length).toBeGreaterThan(0); + for (const member of members) expect(Exit.isExit(member.exit)).toBe(true); + expect(members.map(databaseSecret).filter(Redacted.isRedacted)).toHaveLength(1); + + const cancelled = yield* Effect.promise(() => + test.stack.composition.start({ signal: AbortSignal.abort() }).then( + () => "resolved", + () => "rejected", + ), + ); + expect(cancelled).toBe("rejected"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); + +it.live( + "rejects with the startup failure and removes the stack when Promise test startup fails", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-promise-failure-" }); + const occupied = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); + if (occupied.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP"); + const port = occupied.address.port; + + const failure = yield* Effect.promise(() => + createTestStack({ + services: [{ service: "mail", endpoints: { http: { port } } }], + runtime: "native", + stateRoot, + }).then( + () => undefined, + (error: unknown) => error, + ), + ); + + expect(Schema.is(StackError)(failure)).toBe(true); + expect(Schema.is(StackError)(failure) ? failure.operation : undefined).toBe("test-startup"); + expect(yield* Effect.promise(() => discover({ stateRoot }))).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); diff --git a/packages/stack/src/promise-api.unit.test.ts b/packages/stack/src/promise-api.unit.test.ts new file mode 100644 index 0000000000..499801b95d --- /dev/null +++ b/packages/stack/src/promise-api.unit.test.ts @@ -0,0 +1,156 @@ +import type { Effect } from "effect"; +import { describe, expectTypeOf, it } from "vitest"; +import type { InitializationCommand } from "./Commands.ts"; +import type * as StackEffect from "./effect.ts"; +import type * as PromiseApi from "./index.ts"; +import { createTestStack, makeTestStack } from "./testing.ts"; + +type Kind = StackEffect.ServiceCreationInput["service"]; +type CallOptions = PromiseApi.CallOptions; + +describe("Promise API derived from the Effect API", () => { + it("exposes every Effect stack operation, plus close", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + keyof StackEffect.Stack["services"] + >(); + expectTypeOf().toEqualTypeOf< + keyof StackEffect.Stack["composition"] + >(); + expectTypeOf().toEqualTypeOf< + keyof StackEffect.Stack["commands"] + >(); + }); + + it("exposes every Effect service operation for each service kind", () => { + expectTypeOf<{ [K in Kind]: keyof PromiseApi.ServiceInstances[K] }>().toEqualTypeOf<{ + [K in Kind]: keyof StackEffect.ServiceInstances[K]; + }>(); + }); + + it("turns Effects into cancellable Promise calls", () => { + expectTypeOf().toEqualTypeOf< + (options?: CallOptions) => Promise + >(); + expectTypeOf().toEqualTypeOf< + (options?: CallOptions) => Promise + >(); + }); + + it("gives Effect-returning functions trailing call options", () => { + expectTypeOf().toEqualTypeOf< + ( + key: string, + options?: PromiseApi.DatabaseSnapshotOptions, + callOptions?: CallOptions, + ) => Promise + >(); + expectTypeOf().returns.resolves.toEqualTypeOf< + ReadonlyArray + >(); + expectTypeOf() + .parameter(2) + .toEqualTypeOf(); + }); + + it("runs initialization commands with optional output sinks", () => { + const initialize = (stack: PromiseApi.Stack, command: InitializationCommand) => + stack.commands.run(command); + expectTypeOf>().resolves.toEqualTypeOf<{ + readonly jobId: string; + readonly exitCode: number; + }>(); + }); + + it("accepts plain secrets wherever the Effect API takes Redacted configuration", () => { + expectTypeOf<{ + service: "database"; + config: { version: "17"; jwtExpiry: 3600; databasePassword: string; jwtSecret: string }; + endpoints: {}; + }>().toExtend(); + expectTypeOf<{ + config: { version: string; jwtExpiry: number; databasePassword: string }; + }>().toExtend[0]>(); + }); + + it("turns Streams into async iterables", () => { + expectTypeOf().toEqualTypeOf< + () => AsyncIterable + >(); + }); + + it("types created and returned handles by service kind", () => { + const createDatabase = (stack: PromiseApi.Stack) => + stack.services.create({ + service: "database", + config: { version: "17", jwtExpiry: 3600 }, + endpoints: {}, + }); + const createRest = (stack: PromiseApi.Stack) => + stack.services.create({ service: "rest", config: {}, endpoints: {} }); + type Rest = Awaited>; + expectTypeOf< + Awaited> + >().toEqualTypeOf(); + expectTypeOf<"saveSnapshot" extends keyof Rest ? true : false>().toEqualTypeOf(); + expectTypeOf().returns.resolves.toEqualTypeOf(); + expectTypeOf< + Awaited>[number]["start"] + >().toEqualTypeOf<(options?: CallOptions) => Promise>(); + }); + + it("types test stack services by the selected kinds", () => { + const selectDefault = () => createTestStack().then((test) => test.services); + const selectMany = () => + createTestStack({ services: ["database", { service: "rest", config: {} }] }).then( + (test) => test.services, + ); + expectTypeOf>>().toEqualTypeOf<"database">(); + expectTypeOf>>().toEqualTypeOf<{ + readonly database: PromiseApi.DatabaseInstance; + readonly rest: PromiseApi.ServiceInstances["rest"]; + }>(); + }); + + it("types a test stack kind as present only when every list variant selects it", () => { + const branches = (flag: boolean) => + createTestStack({ services: flag ? ["database", "auth"] : ["database"] }).then( + (test) => test.services, + ); + expectTypeOf>>().toEqualTypeOf<{ + readonly database: PromiseApi.DatabaseInstance; + readonly auth?: PromiseApi.ServiceInstances["auth"]; + }>(); + const element = (flag: boolean) => + createTestStack({ services: ["database", flag ? "auth" : "rest"] }).then( + (test) => test.services, + ); + expectTypeOf>>().toEqualTypeOf<{ + readonly database: PromiseApi.DatabaseInstance; + readonly auth?: PromiseApi.ServiceInstances["auth"]; + readonly rest?: PromiseApi.ServiceInstances["rest"]; + }>(); + const effect = (flag: boolean) => + makeTestStack({ services: ["database", flag ? "auth" : "rest"] }); + expectTypeOf>["services"]>().toEqualTypeOf<{ + readonly database: StackEffect.DatabaseInstance; + readonly auth?: StackEffect.ServiceInstances["auth"]; + readonly rest?: StackEffect.ServiceInstances["rest"]; + }>(); + }); + + it("types test stack services from a list without a static length as optional", () => { + const services: ReadonlyArray<"database" | "rest"> = ["database"]; + const selectPromise = () => createTestStack({ services }).then((test) => test.services); + expectTypeOf>>().toEqualTypeOf<{ + readonly database?: PromiseApi.DatabaseInstance; + readonly rest?: PromiseApi.ServiceInstances["rest"]; + }>(); + expectTypeOf< + Effect.Success>>["services"] + >().toEqualTypeOf<{ + readonly database?: StackEffect.DatabaseInstance; + readonly rest?: StackEffect.ServiceInstances["rest"]; + }>(); + }); +}); diff --git a/packages/stack/src/public.e2e.test.ts b/packages/stack/src/public.e2e.test.ts index 36a34655a9..f8f33a415c 100644 --- a/packages/stack/src/public.e2e.test.ts +++ b/packages/stack/src/public.e2e.test.ts @@ -4,7 +4,7 @@ import { Effect, FileSystem, Layer, Path, Redacted, Schema, Stream } from "effec import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; -import { open } from "./effect.ts"; +import { discover, open } from "./effect.ts"; import { postgres } from "./Commands.ts"; import * as PromiseStack from "./index.ts"; import { assertOwnerExited, captureOwnerPid } from "../tests/owner.ts"; @@ -426,3 +426,41 @@ it.live( ), { timeout: 60_000 }, ); + +for (const runtime of ["node", "bun"] as const) { + it.live( + `${runtime}: a Promise test stack resets to its checkpoint and is destroyed on disposal`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ + prefix: `stack-test-client-${runtime}-`, + }); + const child = yield* ChildProcess.make( + runtime === "bun" ? process.execPath : "node", + [new URL("../tests/test-stack-client.ts", import.meta.url).pathname, stateRoot], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText, Stream.mkString), + child.stderr.pipe(Stream.decodeText, Stream.mkString), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + expect(Number(code), stderr).toBe(0); + const disposed = yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ stackId: Schema.String, projectRoot: Schema.String }), + ), + )(stdout.trim()); + expect(yield* discover({ stateRoot })).toEqual([]); + expect(yield* fs.exists(disposed.projectRoot)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 300_000 }, + ); +} diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index 54d264eb8c..31a4a7ad2e 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -31,6 +31,7 @@ import { } from "./Recipe.ts"; import { makeProcessRecipe, type ProcessDependencies } from "./ProcessRecipe.ts"; import { missingInput } from "./ServiceConfig.ts"; +import type { SnapshotScope } from "./DatabaseSnapshot.ts"; import { slimImageMirrors, type ServiceKind } from "../Artifacts.ts"; import type { ServiceInstanceContext } from "../Service.ts"; @@ -239,10 +240,10 @@ const databaseRecipe = ( : Effect.fail( new CatalogError({ operation: "reset", message: "Service kind cannot change" }), ), - saveDatabaseSnapshot: (context, key) => + saveDatabaseSnapshot: (context, key, scope) => context.config.service === "database" ? component - .saveSnapshot({ ...context, config: context.config.config }, key) + .saveSnapshot({ ...context, config: context.config.config }, key, scope) .pipe( Effect.mapError( (cause) => @@ -252,10 +253,10 @@ const databaseRecipe = ( : Effect.fail( new CatalogError({ operation: "snapshot-save", message: "Service kind cannot change" }), ), - restoreDatabaseSnapshot: (context, key) => + restoreDatabaseSnapshot: (context, key, scope) => context.config.service === "database" ? component - .restoreSnapshot({ ...context, config: context.config.config }, key) + .restoreSnapshot({ ...context, config: context.config.config }, key, scope) .pipe( Effect.mapError( (cause) => @@ -433,9 +434,11 @@ export type CatalogRecipe = RecipeCatalogRecipe & { readonly saveDatabaseSnapshot?: ( context: ServiceInstanceContext, key: string, + scope: SnapshotScope, ) => Effect.Effect; readonly restoreDatabaseSnapshot?: ( context: ServiceInstanceContext, key: string, + scope: SnapshotScope, ) => Effect.Effect; }; diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 3ae0ee0dae..4f4b52017c 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -64,11 +64,16 @@ import { } from "../runtime/postgres-user.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; -import { makeDatabaseSnapshots } from "./DatabaseSnapshot.ts"; +import { + instanceSnapshotsDirectory, + makeDatabaseSnapshots, + type SnapshotScope, +} from "./DatabaseSnapshot.ts"; import type { DockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; import { makeDockerDatabaseStorage, type DockerDatabaseStorage, + type DockerDatabaseStorageError, } from "../storage/DockerDatabaseStorage.ts"; export const DatabaseConfig = Schema.Struct({ @@ -138,10 +143,12 @@ export interface DatabaseComponent { readonly saveSnapshot: ( context: ServiceInstanceContext, key: string, + scope: SnapshotScope, ) => Effect.Effect; readonly restoreSnapshot: ( context: ServiceInstanceContext, key: string, + scope: SnapshotScope, ) => Effect.Effect; readonly endpoint: Effect.Effect; readonly logs: Stream.Stream; @@ -384,6 +391,8 @@ const removeOwnedRoot = Effect.fn("Database.removeOwnedRoot")(( stackId: string, instanceId: string, removeData: Effect.Effect, + /** Root entries kept with the owner marker; when empty the root itself is removed. */ + keep: ReadonlyArray = [], ): Effect.Effect => { const ownerFile = path.join(root, ".supabase-database-owner.json"); const marker = JSON.stringify({ stackId, instanceId }); @@ -402,9 +411,18 @@ const removeOwnedRoot = Effect.fn("Database.removeOwnedRoot")(( if (existing !== marker) return yield* errorFor("destroy", "Database root belongs to another instance"); yield* removeData; - yield* fs - .remove(root, { recursive: true, force: true }) + if (keep.length === 0) + return yield* fs + .remove(root, { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); + const names = yield* fs + .readDirectory(root) .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); + for (const name of names) + if (name !== path.basename(ownerFile) && !keep.includes(name)) + yield* fs + .remove(path.join(root, name), { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); }); }); @@ -943,53 +961,46 @@ export const makeDatabase = ( }).pipe(Effect.mapError((cause) => errorFor("destroy", cause))), ), }; - const resetData = Effect.fn("Database.resetData")( - (context: ServiceInstanceContext) => + const resetData = Effect.fn("Database.resetData")(( + context: ServiceInstanceContext, + ) => { + const clear: Effect.Effect = storage === undefined - ? definition - .removeData(context) - .pipe( - Effect.andThen( - ensureOwnedRoot( - fs, - path, - instanceRoot, - String(options.stackId), - options.instanceId, - ).pipe(Effect.mapError((cause) => errorFor("reset", cause))), - ), - ) - : storage.removeData(postgresVersion(context.config.version)).pipe( - Effect.andThen( - ensureOwnedRoot( - fs, - path, - instanceRoot, - String(options.stackId), - options.instanceId, - ).pipe(Effect.mapError((cause) => errorFor("reset", cause))), - ), - Effect.mapError((cause) => errorFor("reset", cause)), - ), - ); + ? removeOwnedRoot( + fs, + path, + instanceRoot, + String(options.stackId), + options.instanceId, + Effect.void, + [instanceSnapshotsDirectory], + ) + : storage.removeData(postgresVersion(context.config.version)); + return clear.pipe( + Effect.andThen( + ensureOwnedRoot(fs, path, instanceRoot, String(options.stackId), options.instanceId), + ), + Effect.mapError((cause) => errorFor("reset", cause)), + ); + }); return { definition, resetData, - saveSnapshot: (context, key) => + saveSnapshot: (context, key, scope) => storage === undefined ? Effect.flatMap(snapshots(context.config.version), (store) => - store.saveSnapshot(key), + store.saveSnapshot(key, scope), ).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))) : storage - .saveSnapshot(postgresVersion(context.config.version), key) + .saveSnapshot(postgresVersion(context.config.version), key, scope) .pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), - restoreSnapshot: (context, key) => + restoreSnapshot: (context, key, scope) => storage === undefined ? Effect.flatMap(snapshots(context.config.version), (store) => - store.restoreSnapshot(key), + store.restoreSnapshot(key, scope), ).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))) : storage - .restoreSnapshot(postgresVersion(context.config.version), key) + .restoreSnapshot(postgresVersion(context.config.version), key, scope) .pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), endpoint: Ref.get(endpoint).pipe( Effect.flatMap((value) => diff --git a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts index 50a83edd3a..4d3bbc131e 100644 --- a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts +++ b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts @@ -8,7 +8,7 @@ import { makeDockerDatabaseStorage } from "../storage/DockerDatabaseStorage.ts"; import { makeDockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; import { shellQuote } from "../storage/DockerSnapshotBackend.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; -import { makeDatabaseSnapshots } from "./DatabaseSnapshot.ts"; +import { makeDatabaseSnapshots, type SnapshotScope } from "./DatabaseSnapshot.ts"; const version = "17.6.1.173"; @@ -74,10 +74,15 @@ interface Instance { readonly entries: string; readonly stages: string; readonly restoreStages: string; - readonly saveSnapshot: (key: string) => Effect.Effect; - readonly restoreSnapshot: (key: string) => Effect.Effect; + readonly saveSnapshot: (key: string, scope?: SnapshotScope) => Effect.Effect; + readonly restoreSnapshot: ( + key: string, + scope?: SnapshotScope, + ) => Effect.Effect; /** Writes a stopped, ready database whose fixture files hold `value`. */ readonly seed: (value: string) => Effect.Effect; + /** Removes the database data the way a database reset does. */ + readonly clear: Effect.Effect; readonly destroy: Effect.Effect; } @@ -146,6 +151,10 @@ const native = Effect.fnUntraced(function* () { readyMarker("native"), ); }).pipe(Effect.orDie), + clear: Effect.all([ + fs.remove(data, { recursive: true, force: true }), + fs.remove(`${instanceRoot}/.supabase-database-ready.json`, { force: true }), + ]).pipe(Effect.asVoid, Effect.orDie), destroy: fs.remove(instanceRoot, { recursive: true }).pipe(Effect.orDie), }; return instance; @@ -256,8 +265,8 @@ const docker = Effect.fnUntraced(function* () { entries: `${cache}/entries`, stages: `${cache}/stages`, restoreStages: `${cache}/stages`, - saveSnapshot: (key) => storage.saveSnapshot(version, key), - restoreSnapshot: (key) => storage.restoreSnapshot(version, key), + saveSnapshot: (key, scope) => storage.saveSnapshot(version, key, scope), + restoreSnapshot: (key, scope) => storage.restoreSnapshot(version, key, scope), seed: (value) => Effect.gen(function* () { yield* storage.prepare(version); @@ -270,6 +279,7 @@ const docker = Effect.fnUntraced(function* () { readyMarker("docker"), ); }).pipe(Effect.orDie), + clear: storage.removeData(version).pipe(Effect.orDie), destroy: storage .destroyData(version) .pipe(Effect.andThen(fs.remove(instanceRoot, { recursive: true })), Effect.orDie), @@ -534,6 +544,46 @@ for (const { name, make } of engines) ), ); + it.live("keeps instance snapshots beyond cache retention and outside the cache", () => + live( + Effect.gen(function* () { + const { engine, entries, fixture } = yield* setup(make); + const owner = yield* engine.instance("owner"); + for (const key of ["one", "two", "three", "four"]) { + yield* owner.seed(`checkpoint-${key}`); + yield* owner.saveSnapshot(key, "instance"); + } + const other = yield* engine.instance("other"); + for (const key of ["a", "b", "c", "d"]) { + yield* other.seed(key); + yield* other.saveSnapshot(key); + } + + yield* owner.clear; + expect(yield* owner.restoreSnapshot("one", "instance")).toBe(true); + expect(yield* fixture(owner)).toBe("checkpoint-one\ncheckpoint-one"); + expect([...(yield* entries(other)).keys()].sort()).toEqual(["b", "c", "d"]); + }), + ), + ); + + it.live("restores instance snapshots only into their instance and removes them with it", () => + live( + Effect.gen(function* () { + const { engine } = yield* setup(make); + const owner = yield* engine.instance("owner"); + yield* owner.seed("checkpoint"); + yield* owner.saveSnapshot("key", "instance"); + + const other = yield* engine.instance("other"); + expect(yield* other.restoreSnapshot("key", "instance")).toBe(false); + yield* owner.destroy; + const recreated = yield* engine.instance("owner"); + expect(yield* recreated.restoreSnapshot("key", "instance")).toBe(false); + }), + ), + ); + it.live("restores snapshots after the source instance is destroyed", () => live( Effect.gen(function* () { diff --git a/packages/stack/src/services/DatabaseSnapshot.ts b/packages/stack/src/services/DatabaseSnapshot.ts index f6be1cdac9..eb335fcaa1 100644 --- a/packages/stack/src/services/DatabaseSnapshot.ts +++ b/packages/stack/src/services/DatabaseSnapshot.ts @@ -71,10 +71,20 @@ export type SnapshotRun = Data.TaggedEnum<{ }>; export const SnapshotRun = Data.taggedEnum(); +/** + * Where a snapshot lives: the shared cache keeps a bounded number of entries across stacks, and an + * instance keeps its own entries, outside that retention, until the instance is destroyed. + */ +export const snapshotScopes = ["cache", "instance"] as const; +export type SnapshotScope = (typeof snapshotScopes)[number]; + +/** Directory in a database instance root that holds its instance-scoped snapshots. */ +export const instanceSnapshotsDirectory = ".supabase-snapshots"; + /** Filesystem namespace in which one engine stores snapshots and database data. */ export interface SnapshotBackend { - /** Identifies the snapshot store for the host-side lock. */ - readonly lockKey: string; + /** Host file whose SQLite write lock serializes operations on this snapshot store. */ + readonly lockFile: string; readonly entries: string; readonly stages: string; /** Holds restore stages on the filesystem that holds `data`. */ @@ -87,7 +97,7 @@ export interface SnapshotBackend { ) => Effect.Effect; } -/** Snapshot entries kept besides the one just saved. */ +/** Cache-scoped entries kept besides the one just saved; instance-scoped checkpoints are never pruned. */ const retainedPrevious = 2; const format = "supabase-database-snapshot-v1" as const; const runtimes = Schema.Literals(["native", "docker", "podman"]); @@ -138,10 +148,9 @@ const withStoreLock = (lockFile: string, effect: Effect.Effect) => }), ); -/** Saves and restores database data through the snapshot protocol on one backend. */ +/** Saves and restores database data through the snapshot protocol, with one backend per scope. */ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(function* (options: { - readonly backend: SnapshotBackend; - readonly cacheRoot: string; + readonly backends: { readonly [Scope in SnapshotScope]: SnapshotBackend }; readonly instanceRoot: string; readonly runtime: DatabaseRuntime; readonly version: string; @@ -149,12 +158,11 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const { backend, runtime, version } = options; + const { backends, runtime, version } = options; const { Adopt, Clear, Copy, Ensure, Expect, ExpectEmpty, ExpectText } = SnapshotStep; const { Prune, Recover, Remove, Rename, Touch, Write } = SnapshotStep; const major = version.split(".")[0] ?? version; const markerPath = path.join(options.instanceRoot, ".supabase-database-ready.json"); - const locks = path.join(options.cacheRoot, "stack-database-snapshots", "locks"); const mapError = (operation: string, effect: Effect.Effect) => effect.pipe(Effect.mapError((cause) => errorFor(operation, cause))); @@ -184,15 +192,16 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio Effect.mapError((cause) => errorFor("descriptor", cause)), ); - const locked = (effect: Effect.Effect) => - mapError("lock", fs.makeDirectory(locks, { recursive: true, mode: 0o700 })).pipe( - Effect.andThen(withStoreLock(path.join(locks, `${backend.lockKey}.sqlite`), effect)), - ); + const locked = (backend: SnapshotBackend, effect: Effect.Effect) => + mapError( + "lock", + fs.makeDirectory(path.dirname(backend.lockFile), { recursive: true, mode: 0o700 }), + ).pipe(Effect.andThen(withStoreLock(backend.lockFile, effect))); const token = mapError("stage", crypto.randomUUIDv4); // Compensation also runs after an interrupt; a failed compensation must not hide the // failure it follows. const compensate = - (steps: ReadonlyArray) => + (backend: SnapshotBackend, steps: ReadonlyArray) => (effect: Effect.Effect) => effect.pipe( Effect.onExit((exit) => @@ -203,7 +212,7 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio ), ), ); - const reclaimStages = [ + const reclaimStages = (backend: SnapshotBackend) => [ Recover({ stages: backend.stages, entries: backend.entries }), Clear({ directory: backend.stages }), ...(backend.restoreStages === backend.stages @@ -211,7 +220,11 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio : [Clear({ directory: backend.restoreStages })]), ]; - const saveSnapshot = Effect.fn("DatabaseSnapshot.save")(function* (logicalKey: string) { + const saveSnapshot = Effect.fn("DatabaseSnapshot.save")(function* ( + logicalKey: string, + scope: SnapshotScope = "cache", + ) { + const backend = backends[scope]; const ready = yield* mapError( "ready", fs @@ -222,6 +235,7 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio return yield* errorFor("ready", "Database readiness marker does not match the instance"); const { keyDigest, descriptor } = yield* describe(logicalKey); yield* locked( + backend, Effect.gen(function* () { const id = yield* token; const stage = backend.join(backend.stages, id); @@ -230,7 +244,7 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio const result = yield* backend .run([ Ensure({ directory: backend.entries }), - ...reclaimStages, + ...reclaimStages(backend), Expect({ path: backend.join(backend.data, "postmaster.pid"), present: false, @@ -248,10 +262,12 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio Rename({ from: stage, to: target, optional: false }), Remove({ path: retired }), Touch({ path: target }), - Prune({ directory: backend.entries, keep: retainedPrevious, except: keyDigest }), + ...(scope === "cache" + ? [Prune({ directory: backend.entries, keep: retainedPrevious, except: keyDigest })] + : []), ]) .pipe( - compensate([ + compensate(backend, [ Rename({ from: retired, to: target, optional: true }), Remove({ path: stage }), ]), @@ -280,9 +296,14 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio Effect.mapError((cause) => errorFor("ready", cause)), ); - const restoreSnapshot = Effect.fn("DatabaseSnapshot.restore")(function* (logicalKey: string) { + const restoreSnapshot = Effect.fn("DatabaseSnapshot.restore")(function* ( + logicalKey: string, + scope: SnapshotScope = "cache", + ) { + const backend = backends[scope]; const { keyDigest, descriptor } = yield* describe(logicalKey); return yield* locked( + backend, Effect.gen(function* () { const id = yield* token; const stage = backend.join(backend.restoreStages, id); @@ -299,7 +320,7 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio const result = yield* restore( backend.run([ Ensure({ directory: backend.entries }), - ...reclaimStages, + ...reclaimStages(backend), ExpectEmpty({ directory: backend.data, otherwise: "nonempty" }), Expect({ path: entry, present: true, otherwise: "miss" }), ExpectText({ @@ -350,7 +371,7 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio yield* publishReadyMarker(id); return true; }), - ).pipe(compensate(rollback)); + ).pipe(compensate(backend, rollback)); }), ); }); @@ -358,15 +379,16 @@ export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(functio return { saveSnapshot, restoreSnapshot }; }); -/** Interprets snapshot programs directly on the host filesystem. */ -const makeNativeSnapshotBackend = Effect.fnUntraced(function* ( - instanceRoot: string, - cacheRoot: string, -) { +/** Interprets snapshot programs directly on the host filesystem, keeping entries under `root`. */ +const makeNativeSnapshotBackend = Effect.fnUntraced(function* (options: { + readonly instanceRoot: string; + readonly root: string; + readonly lockFile: string; +}) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const root = path.join(cacheRoot, "stack-database-snapshots"); + const { instanceRoot, root } = options; const exists = (target: string) => fs.exists(target); const isEmptyDirectory = (target: string) => fs.readDirectory(target).pipe(Effect.map((names) => names.length === 0)); @@ -470,7 +492,7 @@ const makeNativeSnapshotBackend = Effect.fnUntraced(function* ( }), }); const backend: SnapshotBackend = { - lockKey: "native", + lockFile: options.lockFile, entries: path.join(root, "entries"), stages: path.join(root, "stages"), restoreStages: path.join(instanceRoot, ".supabase-restore"), @@ -489,16 +511,31 @@ const makeNativeSnapshotBackend = Effect.fnUntraced(function* ( return backend; }); -/** Snapshots a native database instance into the host cache. */ +/** Wires a native database instance's cache-scoped and instance-scoped snapshot backends. */ export const makeDatabaseSnapshots = Effect.fn("DatabaseSnapshot.make")(function* (options: { readonly instanceRoot: string; readonly cacheRoot: string; readonly runtime: DatabaseRuntime; readonly version: string; }) { + const path = yield* Path.Path; + const cache = path.join(options.cacheRoot, "stack-database-snapshots"); + const instance = path.join(options.instanceRoot, instanceSnapshotsDirectory); + // Both backends share the instance's data and restore stages. Service.storage runs one operation + // per instance at a time, so each lock only guards its own store across processes. return yield* makeSnapshotStore({ - backend: yield* makeNativeSnapshotBackend(options.instanceRoot, options.cacheRoot), - cacheRoot: options.cacheRoot, + backends: { + cache: yield* makeNativeSnapshotBackend({ + instanceRoot: options.instanceRoot, + root: cache, + lockFile: path.join(cache, "locks", "native.sqlite"), + }), + instance: yield* makeNativeSnapshotBackend({ + instanceRoot: options.instanceRoot, + root: instance, + lockFile: path.join(instance, "lock.sqlite"), + }), + }, instanceRoot: options.instanceRoot, runtime: options.runtime, version: postgresVersion(options.version), diff --git a/packages/stack/src/services/ServiceConfig.ts b/packages/stack/src/services/ServiceConfig.ts index f13d71036e..72156d14a0 100644 --- a/packages/stack/src/services/ServiceConfig.ts +++ b/packages/stack/src/services/ServiceConfig.ts @@ -7,7 +7,7 @@ import { DEFAULT_LOCAL_SECRET_KEY, DEFAULT_SIGNING_KEY, } from "../Defaults.ts"; -import type { StackCredentials, StackIdentityInput } from "../State.ts"; +import type { StackCredentials, StackKeysInput } from "../State.ts"; const serviceError = (operation: string, cause: unknown): ServiceError => cause instanceof ServiceError @@ -69,7 +69,7 @@ const jsonArray = (value: string, field: string) => ); export const resolveStackKeys = Effect.fn("ServiceConfig.resolveStackKeys")( - (jwtSecret: string, input: StackIdentityInput | undefined, saved: StackCredentials | undefined) => + (jwtSecret: string, input: StackKeysInput | undefined, saved: StackCredentials | undefined) => Effect.gen(function* () { if (input === undefined && saved !== undefined) return saved; const defaults = yield* defaultStackKeys(jwtSecret); diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 402cfd6a06..7a0be70b33 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -650,8 +650,16 @@ describe("Docker database storage", { timeout: 120_000 }, () => { const cacheRoot = path.join(root, "cache"); const instanceRoot = path.join(storageRoot, "unmarked"); const dataRoot = path.join(instanceRoot, "data"); + const checkpointsRoot = path.join(instanceRoot, ".supabase-snapshots"); yield* fs.makeDirectory(path.join(dataRoot, "base"), { recursive: true }); yield* fs.writeFileString(path.join(dataRoot, "base", "fixture"), "unmarked"); + yield* fs.makeDirectory(path.join(checkpointsRoot, "entries", "checkpoint", "data"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(checkpointsRoot, "entries", "checkpoint", "data", "PG_VERSION"), + "17", + ); yield* docker([ "run", "--rm", @@ -660,7 +668,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { helperImage, "/bin/sh", "-c", - "chown -R 100:101 /instance/data; chmod -R 700 /instance/data", + "chown -R 100:101 /instance/data /instance/.supabase-snapshots; chmod -R 700 /instance/data /instance/.supabase-snapshots", ]); const container = yield* makeContainerRuntime({ engine: "docker", root }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -685,6 +693,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { yield* storage.destroyData("17"); expect(yield* fs.exists(dataRoot)).toBe(false); + expect(yield* fs.exists(checkpointsRoot)).toBe(false); expect(yield* fs.exists(markerPath)).toBe(false); yield* fs.remove(cacheRoot, { recursive: true, force: true }); }), @@ -1043,6 +1052,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ]); if (process.platform === "linux") expect(expectedOwnership).toBe("100:101"); yield* storage.saveSnapshot("17", "adopted"); + yield* storage.saveSnapshot("17", "checkpoint", "instance"); const nativeRoot = path.join(root, "native"); yield* fs.makeDirectory(path.join(nativeRoot, "data"), { recursive: true }); yield* fs.writeFileString(path.join(nativeRoot, "data", "PG_VERSION"), "17\n"); @@ -1074,11 +1084,14 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ]), ).toBe(expectedOwnership); yield* storage.removeData("17"); + expect(yield* storage.restoreSnapshot("17", "checkpoint", "instance")).toBe(true); + yield* storage.removeData("17"); yield* storage.destroyData("unsupported"); expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-storage.json"))).toBe( true, ); expect(yield* fs.exists(dataRoot)).toBe(false); + expect(yield* fs.exists(path.join(instanceRoot, ".supabase-snapshots"))).toBe(false); yield* fs.remove(cacheRoot, { recursive: true }); }), ).pipe(Effect.provide(NodeServices.layer)), diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 3b016a22b8..26c80142b1 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -17,7 +17,12 @@ import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; import { failureMessage } from "../internal/failure-message.ts"; import type { ContainerRuntime } from "../runtime/Container.ts"; import type { DatabaseRuntime } from "../services/Database.ts"; -import { DatabaseSnapshotError, makeSnapshotStore } from "../services/DatabaseSnapshot.ts"; +import { + DatabaseSnapshotError, + instanceSnapshotsDirectory, + makeSnapshotStore, + type SnapshotScope, +} from "../services/DatabaseSnapshot.ts"; import type { DockerHelperRegistry } from "./DockerHelperRegistry.ts"; import { makeDockerSnapshotBackend, shellQuote } from "./DockerSnapshotBackend.ts"; @@ -71,10 +76,12 @@ export interface DockerDatabaseStorage { readonly saveSnapshot: ( version: string, key: string, + scope?: SnapshotScope, ) => Effect.Effect; readonly restoreSnapshot: ( version: string, key: string, + scope?: SnapshotScope, ) => Effect.Effect; } @@ -766,11 +773,19 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") .remove(readyMarkerPath, { force: true }) .pipe(Effect.mapError((cause) => errorFor("reset", cause))); /** Unmarked data cannot start, so removal is its only in-product recovery. */ - const removeUnmarkedData = (version: string) => + const removeUnmarkedData = ( + version: string, + { checkpoints }: { readonly checkpoints: boolean }, + ) => Effect.gen(function* () { - if (!(yield* hasUnmarkedData)) return; + const removeCheckpoints = + checkpoints && + (yield* options.fs + .exists(options.path.join(options.instanceRoot, instanceSnapshotsDirectory)) + .pipe(Effect.mapError((cause) => errorFor("data", cause)))); + if (!removeCheckpoints && !(yield* hasUnmarkedData)) return; yield* runHelper( - "set -eu; rm -rf /instance/data /instance/.supabase-restore", + `set -eu; rm -rf /instance/data /instance/.supabase-restore${removeCheckpoints ? ` ${shellQuote(`/instance/${instanceSnapshotsDirectory}`)}` : ""}`, [{ source: options.instanceRoot, target: "/instance", readOnly: false }], version, true, @@ -825,7 +840,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") ); } else { yield* runHelper( - `set -eu; mkdir -p ${shellQuote(`${store}/data`)} ${shellQuote(`${cache}/entries`)} ${shellQuote(`${cache}/stages`)}; chown -R 100:101 ${shellQuote(store)}`, + `set -eu; mkdir -p ${shellQuote(`${store}/data`)} ${shellQuote(`${cache}/entries`)} ${shellQuote(`${cache}/stages`)}; chown -R 100:101 ${shellQuote(`${store}/data`)}`, [{ source: marker.volume ?? "", target: "/store", readOnly: false, type: "volume" }], version, ); @@ -876,7 +891,8 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const removeData = Effect.fn("DockerDatabaseStorage.removeData")((version: string) => Effect.gen(function* () { const markerOption = yield* getMarkerForRemoval; - if (Option.isNone(markerOption)) return yield* removeUnmarkedData(version); + if (Option.isNone(markerOption)) + return yield* removeUnmarkedData(version, { checkpoints: false }); const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( @@ -901,13 +917,13 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.gen(function* () { const markerOption = yield* getMarkerIfPresent; if (Option.isNone(markerOption)) { - yield* removeUnmarkedData(version); + yield* removeUnmarkedData(version, { checkpoints: true }); return yield* removeHelper(); } const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( - `set -eu; rm -rf /instance/data /instance/.supabase-restore`, + `set -eu; rm -rf /instance/data /instance/.supabase-restore ${shellQuote(`/instance/${instanceSnapshotsDirectory}`)}`, snapshotPaths(marker).mounts, version, true, @@ -960,25 +976,48 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const snapshots = (store: Marker, version: string) => { const paths = snapshotPaths(store); const host = store.backend === "host"; + const instanceSnapshotRoot = host + ? `/instance/${instanceSnapshotsDirectory}` + : `/store/${store.namespace}/snapshots`; + const exec = (script: string) => runHelper(script, paths.mounts, version); return makeSnapshotStore({ - backend: makeDockerSnapshotBackend({ - lockKey: host - ? `host-${store.cacheNamespace}` - : `${store.volume ?? "volume"}-${store.cacheNamespace}`, - root: paths.root, - data: paths.source, - restoreStages: host ? "/instance/.supabase-restore" : `${paths.root}/stages`, - // Host-backed snapshots stay removable by the host user; restored data belongs - // to the database user. - ...(host - ? { - adoptOwner: "100:101", - epilogue: `owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${shellQuote(paths.root)}`, - } - : {}), - exec: (script) => runHelper(script, paths.mounts, version), - }), - cacheRoot: options.cacheRoot, + backends: { + cache: makeDockerSnapshotBackend({ + lockFile: options.path.join( + options.cacheRoot, + "stack-database-snapshots", + "locks", + `${host ? "host" : (store.volume ?? "volume")}-${store.cacheNamespace}.sqlite`, + ), + root: paths.root, + data: paths.source, + restoreStages: host ? "/instance/.supabase-restore" : `${paths.root}/stages`, + // Host-backed snapshots stay removable by the host user; restored data belongs + // to the database user. + ...(host + ? { + adoptOwner: "100:101", + epilogue: `owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${shellQuote(paths.root)}`, + } + : {}), + exec, + }), + // Instance snapshots live beside the instance data, so destroying it removes them. + instance: makeDockerSnapshotBackend({ + lockFile: options.path.join( + options.instanceRoot, + instanceSnapshotsDirectory, + "lock.sqlite", + ), + root: instanceSnapshotRoot, + data: paths.source, + restoreStages: host + ? "/instance/.supabase-restore" + : `${instanceSnapshotRoot}/stages`, + ...(host ? { adoptOwner: "100:101" } : {}), + exec, + }), + }, instanceRoot: options.instanceRoot, runtime: options.runtime, version, @@ -989,21 +1028,22 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") ); }; const saveSnapshot = Effect.fn("DockerDatabaseStorage.saveSnapshot")( - (version: string, key: string) => + (version: string, key: string, scope: SnapshotScope = "cache") => Effect.gen(function* () { yield* selected; const store = yield* getMarker; if (!store.initialized) return yield* errorFor("snapshot", "Database is not initialized"); - yield* (yield* snapshots(store, version)).saveSnapshot(key); + yield* (yield* snapshots(store, version)).saveSnapshot(key, scope); }).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), ); const restoreSnapshot = Effect.fn("DockerDatabaseStorage.restoreSnapshot")( - (version: string, key: string) => + (version: string, key: string, scope: SnapshotScope = "cache") => Effect.gen(function* () { yield* selected; const store = yield* getMarker; - if (!(yield* (yield* snapshots(store, version)).restoreSnapshot(key))) return false; + if (!(yield* (yield* snapshots(store, version)).restoreSnapshot(key, scope))) + return false; yield* writeMarker({ ...store, initialized: true }); return true; }).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), diff --git a/packages/stack/src/storage/DockerSnapshotBackend.ts b/packages/stack/src/storage/DockerSnapshotBackend.ts index 4f81881bfb..07addab4f3 100644 --- a/packages/stack/src/storage/DockerSnapshotBackend.ts +++ b/packages/stack/src/storage/DockerSnapshotBackend.ts @@ -90,7 +90,7 @@ const parseRun = (output: string) => /** Runs each snapshot program as one POSIX shell script inside a storage helper container. */ export const makeDockerSnapshotBackend = (options: { - readonly lockKey: string; + readonly lockFile: string; readonly root: string; readonly data: string; readonly restoreStages: string; @@ -102,7 +102,7 @@ export const makeDockerSnapshotBackend = (options: { }): SnapshotBackend => { const onExit = `status=$?; if [ "$status" -ne 0 ]; then echo "${stepMarker}$step" >&2; fi; ${options.epilogue ?? ":"}; exit "$status"`; return { - lockKey: options.lockKey, + lockFile: options.lockFile, entries: `${options.root}/entries`, stages: `${options.root}/stages`, restoreStages: options.restoreStages, diff --git a/packages/stack/src/testing.integration.test.ts b/packages/stack/src/testing.integration.test.ts new file mode 100644 index 0000000000..8f92f1b45e --- /dev/null +++ b/packages/stack/src/testing.integration.test.ts @@ -0,0 +1,246 @@ +import { NodeHttpClient, NodeServices, NodeSocketServer } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { + Cause, + ConfigProvider, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Path, + Stream, +} from "effect"; +import { discover, type Stack } from "./effect.ts"; +import { makeTestStack } from "./testing.ts"; +import { postgres } from "./Commands.ts"; + +const sql = (stack: Stack, databaseUrl: string, command: string) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const result = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "--set", "ON_ERROR_STOP=1", "-tA", "--command", command], + stdout: (bytes) => Effect.sync(() => (stdout += decoder.decode(bytes))), + stderr: (bytes) => Effect.sync(() => (stderr += decoder.decode(bytes))), + }); + if (result.exitCode !== 0) return yield* Effect.die(`psql failed: ${stderr}`); + return stdout.trim(); + }); + +const snapshotDescriptors = (root: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(root))) return []; + const files = yield* fs.readDirectory(root, { recursive: true }); + return files.filter((file) => file.endsWith("descriptor.json")); + }); + +it.live( + "keeps every checkpoint of parallel stacks beyond the snapshot cache bound and removes them on destroy", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-state-" }); + const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-cache-" }); + const exercise = (label: string) => + Effect.gen(function* () { + const test = yield* makeTestStack({ runtime: "native", stateRoot, cacheRoot }); + const { databaseUrl } = yield* test.services.database.credentials(); + if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); + const rows = sql( + test.stack, + databaseUrl, + "SELECT string_agg(value, ',' ORDER BY value) FROM checkpoint_rows", + ); + yield* sql(test.stack, databaseUrl, "CREATE TABLE checkpoint_rows (value text NOT NULL)"); + for (const step of ["0", "1", "2", "3"]) { + yield* sql( + test.stack, + databaseUrl, + `INSERT INTO checkpoint_rows VALUES ('${label}${step}')`, + ); + yield* test.checkpoint(step); + } + + yield* test.reset("0"); + expect(yield* rows).toBe(`${label}0`); + yield* test.reset("2"); + expect(yield* rows).toBe(`${label}0,${label}1,${label}2`); + const unknown = yield* test.reset("unknown").pipe(Effect.flip); + expect(unknown.message).toContain("the database data was not reset"); + expect(yield* rows).toBe(`${label}0,${label}1,${label}2`); + expect(yield* snapshotDescriptors(path.join(stateRoot, test.stack.id))).toHaveLength(4); + }).pipe(Effect.scoped); + + yield* Effect.all([exercise("a"), exercise("b")], { concurrency: "unbounded" }); + + expect(yield* discover({ stateRoot })).toEqual([]); + expect(yield* snapshotDescriptors(stateRoot)).toEqual([]); + expect(yield* snapshotDescriptors(cacheRoot)).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 240_000 }, +); + +it.live( + "warns that data may be partially reset when resetData fails before restoring a checkpoint", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-testing-partial-reset-", + }); + const test = yield* makeTestStack({ runtime: "native", stateRoot }); + yield* test.checkpoint("0"); + + const root = path.join(stateRoot, test.stack.id); + const owners = (yield* fs.readDirectory(root, { recursive: true })).filter((file) => + file.endsWith(".supabase-database-owner.json"), + ); + expect(owners).toHaveLength(1); + const ownerFile = path.join(root, owners[0]!); + const originalMarker = yield* fs.readFileString(ownerFile); + // Released before the stack's teardown, which destroys only data carrying its own marker. + yield* Effect.acquireRelease( + fs.writeFileString( + ownerFile, + `{"stackId":"not-this-stack","instanceId":"not-this-instance"}`, + ), + () => fs.writeFileString(ownerFile, originalMarker).pipe(Effect.orDie), + ); + + const failure = yield* test.reset("0").pipe(Effect.flip); + expect(failure.message).toContain("the database data may have been partially reset"); + expect(failure.message).not.toContain("was reset without restoring checkpoint"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live( + "restarts the composition when a checkpoint is interrupted after the stack stops", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-interrupt-" }); + const test = yield* makeTestStack({ runtime: "native", stateRoot }); + const database = test.services.database; + const subscribed = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + yield* database.followStatus.pipe( + Stream.runForEach((status) => + Deferred.succeed(subscribed, undefined).pipe( + Effect.andThen( + status.lifecycle === "stopped" ? Deferred.succeed(stopped, undefined) : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + + const checkpoint = yield* test.checkpoint("interrupted").pipe(Effect.forkScoped); + yield* Deferred.await(stopped); + yield* Fiber.interrupt(checkpoint); + + expect(Exit.hasInterrupts(yield* Fiber.await(checkpoint))).toBe(true); + const status = yield* database.status; + expect(status.lifecycle).toBe("running"); + expect(status.health).toBe("healthy"); + const { databaseUrl } = yield* database.credentials(); + if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); + expect(yield* sql(test.stack, databaseUrl, "SELECT 1")).toBe("1"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live( + "names the failure, service states and owner log, then removes the stack when test startup fails", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-failure-" }); + const occupied = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); + if (occupied.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP"); + const port = occupied.address.port; + + const startup = yield* makeTestStack({ + services: [{ service: "mail", endpoints: { http: { port } } }], + runtime: "native", + stateRoot, + }).pipe(Effect.scoped, Effect.exit); + + expect(Exit.isFailure(startup)).toBe(true); + const failure = Exit.isFailure(startup) + ? Cause.findErrorOption(startup.cause) + : Option.none(); + if (Option.isNone(failure)) return yield* Effect.die("Expected a typed startup failure"); + expect(failure.value.operation).toBe("test-startup"); + // macOS and Windows refuse the port while claiming it; Linux reports the failed bind. + expect(failure.value.message).toMatch(new RegExp(`\\b${port}\\b.*\\bin use\\b`)); + expect(failure.value.message).toContain("Services: none"); + expect(failure.value.message).toMatch(/Owner log: .+\/owner\.log$/); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live( + "closes a test stack that its test already destroyed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-destroyed-" }); + + const afterDestroy = yield* Effect.gen(function* () { + const test = yield* makeTestStack({ services: ["mail"], runtime: "native", stateRoot }); + yield* test.stack.destroy; + return yield* test.stack.composition.start.pipe(Effect.flip); + }).pipe(Effect.scoped); + + expect(afterDestroy.reason).toBe("owner-unavailable"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live("rejects an unknown SUPABASE_STACK_TEST_RUNTIME before creating a stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-runtime-" }); + + const failure = yield* makeTestStack({ stateRoot }).pipe( + Effect.scoped, + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ SUPABASE_STACK_TEST_RUNTIME: "vm" })), + ), + Effect.flip, + ); + + expect(failure.operation).toBe("test-stack"); + expect(failure.message).toContain("SUPABASE_STACK_TEST_RUNTIME"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), +); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts new file mode 100644 index 0000000000..b92c16d860 --- /dev/null +++ b/packages/stack/src/testing.ts @@ -0,0 +1,367 @@ +import { Config, Crypto, Effect, FileSystem, Option, Path, Ref, Schema, Scope } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem exposes no OS temp or home directory path for the shared roots. +import { homedir, tmpdir, userInfo } from "node:os"; +import type { PlatformError } from "effect/PlatformError"; +import { defaultRuntime } from "./Artifacts.ts"; +import * as StackEffect from "./effect.ts"; +import { + acquire, + stackAdapter, + type CallOptions, + type Plain, + type Promised, + type ServiceHandles, + type ServiceInstances, + type Stack, +} from "./PromiseClient.ts"; +import { StackError, stackError } from "./Rpc.ts"; +import { + allowedEndpointNames, + ServiceCreationInput as CreationSchema, +} from "./services/Catalog.ts"; + +type Kind = StackEffect.ServiceCreationInput["service"]; +type Creation = Extract; + +/** A service kind to compose, optionally with config and endpoint overrides. */ +export type TestService = K extends Kind + ? + | K + | { + readonly service: K; + readonly config?: Partial["config"]>; + readonly endpoints?: Creation["endpoints"]; + } + : never; +/** A test service with plain configuration values. */ +export type PlainTestService = Plain; +/** The service kinds a test stack selects. */ +export type TestServiceKind = S extends Kind + ? S + : S extends { readonly service: infer K extends Kind } + ? K + : never; + +type UnionToIntersection = (U extends unknown ? (value: U) => void : never) extends ( + value: infer I, +) => void + ? I + : never; +/** A kind when it is exactly one kind, not a union a conditional element may pick from. */ +type SingleKind = [K] extends [never] ? never : [K] extends [UnionToIntersection] ? K : never; +/** Kinds one service list always selects; none when its length is not static. */ +type SelectedKinds = + T extends ReadonlyArray + ? number extends T["length"] + ? never + : { [I in keyof T]: SingleKind> }[number] + : never; +/** Kinds every variant of a service list selects, so their handles are always present. */ +type RequiredKinds = + UnionToIntersection } : never> extends { + readonly kinds: infer K extends Kind; + } + ? K + : never; + +/** Test stack options; omitted roots use shared per-user or temporary locations. */ +export interface TestStackOptions { + /** Defaults to `["database"]`. */ + readonly services?: S; + /** Defaults to `SUPABASE_STACK_TEST_RUNTIME`, then the platform's default runtime. */ + readonly runtime?: StackEffect.CreateOptions["runtime"]; + readonly stateRoot?: string; + readonly cacheRoot?: string; + /** + * A caller-owned project root; by default a temporary one is removed on disposal. Default + * Storage and Functions directories live in the temporary root, never in a caller's root. + */ + readonly projectRoot?: string; +} + +/** A composed, ready session stack that is destroyed when its scope closes. */ +export interface EffectTestStack< + Required extends Kind = "database", + Optional extends Kind = never, +> { + readonly stack: StackEffect.Stack; + /** Handles by kind; a kind that some variant of the service list omits is optional. */ + readonly services: ServiceHandles; + readonly projectRoot: string; + /** + * Saves the database data under `name` as an instance snapshot, which other stacks cannot evict + * and destroying this stack removes; the composition stops and restarts around it. + */ + readonly checkpoint: (name: string) => Effect.Effect; + /** + * Restores the data saved by `checkpoint(name)` and waits until every service is ready. An + * unknown name fails before any data changes; the composition restarts even when reset fails. + */ + readonly reset: (name: string) => Effect.Effect; +} + +/** A composed, ready session stack; disposal destroys it, then closes its client. */ +export interface TestStack< + Required extends Kind = "database", + Optional extends Kind = never, +> extends AsyncDisposable { + readonly stack: Stack; + /** Handles by kind; a kind that some variant of the service list omits is optional. */ + readonly services: ServiceHandles; + readonly projectRoot: string; + readonly checkpoint: Promised; + readonly reset: Promised; +} + +const runtimeOverride = Config.option( + Config.literals(["native", "docker", "podman"], "SUPABASE_STACK_TEST_RUNTIME"), +); + +const testFailure = (operation: string) => (cause: unknown) => stackError(operation, cause); + +/** Local-development configuration for each kind, with every endpoint on an automatic port. */ +const localCreation = Effect.fnUntraced(function* ( + kind: Kind, + dataRoot: Effect.Effect, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = Effect.fnUntraced(function* (name: string) { + const target = path.join(yield* dataRoot, name); + yield* fs.makeDirectory(target, { recursive: true }); + return target; + }); + const config = + kind === "database" + ? { version: "17", jwtExpiry: 3600 } + : kind === "storage" || kind === "imgproxy" + ? { filePath: yield* directory("storage") } + : kind === "functions" + ? { functionsRoot: yield* directory("functions") } + : {}; + const endpoints = Object.fromEntries( + allowedEndpointNames(kind).map((name) => [name, { port: "auto" }]), + ); + return { config, endpoints }; +}); + +function byKind( + members: ReadonlyArray, +): ServiceHandles; +function byKind( + members: ReadonlyArray, +): Readonly> { + return Object.fromEntries(members.map((member) => [member.service, member])); +} + +const make = Effect.fn("TestStack.make")( + function* ( + options: TestStackOptions>, + decode: (creation: unknown) => Effect.Effect, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const runtime = + options.runtime ?? Option.getOrUndefined(yield* runtimeOverride) ?? defaultRuntime(); + // Shared roots are created owner-only, so each OS user needs its own. + const user = process.getuid?.() ?? userInfo().username; + const stateRoot = options.stateRoot ?? path.join(tmpdir(), `supabase-stack-tests-${user}`); + const cacheRoot = options.cacheRoot ?? path.join(tmpdir(), `supabase-stack-artifacts-${user}`); + // Native Edge Runtime on Linux cannot read project files below /tmp. + const temporaryRoot = fs.makeTempDirectoryScoped({ + prefix: "supabase-test-stack-", + ...(runtime === "native" && process.platform === "linux" ? { directory: homedir() } : {}), + }); + const projectRoot = options.projectRoot ?? (yield* temporaryRoot); + const dataRoot = + options.projectRoot === undefined + ? Effect.succeed(projectRoot) + : yield* Effect.cached(temporaryRoot); + const services: ReadonlyArray = options.services ?? [ + "database", + ]; + const creations = yield* Effect.forEach(services, (service) => + Effect.gen(function* () { + const kind = typeof service === "string" ? service : service.service; + const local = yield* localCreation(kind, dataRoot); + return yield* decode({ + service: kind, + config: { ...local.config, ...(typeof service === "string" ? {} : service.config) }, + endpoints: { + ...local.endpoints, + ...(typeof service === "string" ? {} : service.endpoints), + }, + }); + }), + ); + const stack = yield* Effect.acquireRelease( + StackEffect.create({ + projectRoot, + stateRoot, + cacheRoot, + runtime, + name: `test-${yield* crypto.randomUUIDv4}`, + lifetime: "session", + }), + (created) => created.destroy.pipe(Effect.orDie), + ); + const diagnose = (operation: string) => (cause: StackError) => + stack.services.list.pipe( + Effect.flatMap((registered) => + Effect.forEach(registered, (member) => + member.status.pipe( + Effect.map( + (status) => + `${member.service}=${status.lifecycle}` + + `${status.health === undefined ? "" : `/${status.health}`}` + + `${status.error === undefined ? "" : ` (${status.error.message})`}`, + ), + Effect.orElseSucceed(() => `${member.service}=unobservable`), + ), + ), + ), + Effect.orElseSucceed((): ReadonlyArray => []), + Effect.flatMap((statuses) => + Effect.fail( + new StackError({ + ...cause, + operation, + message: `${cause.message}\nServices: ${statuses.length === 0 ? "none" : statuses.join(", ")}\nOwner log: ${path.join(stateRoot, stack.id, "owner.log")}`, + }), + ), + ), + ); + const members = yield* stack.composition + .supabase(creations, { eager: true }) + .pipe(Effect.catch(diagnose("test-startup"))); + const start = (operation: string) => + stack.composition.start.pipe( + Effect.andThen( + Effect.forEach(members, (member) => member.ready, { + concurrency: "unbounded", + discard: true, + }), + ), + Effect.catch(diagnose(operation)), + ); + yield* start("test-startup"); + const database = members.find( + (member): member is StackEffect.DatabaseInstance => member.service === "database", + ); + const checkpoints = yield* Ref.make>(new Set()); + const withContext = (suffix: string) => + Effect.mapError( + (failure: StackError) => + new StackError({ ...failure, message: `${failure.message}; ${suffix}` }), + ); + /** Stops the composition around `work` and brings it back whether or not `work` succeeds. */ + const whileStopped = ( + operation: string, + work: (database: StackEffect.DatabaseInstance) => Effect.Effect, + ) => + database === undefined + ? Effect.fail( + new StackError({ operation, message: "Test stack checkpoints require a database" }), + ) + : // The owner completes a stop its caller abandons, so the restart must follow its reply. + Effect.uninterruptible(stack.composition.stop).pipe( + Effect.andThen(work(database)), + Effect.matchEffect({ + onSuccess: () => start(operation), + onFailure: (failure) => + start(operation).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.fail(failure), + onFailure: (restart) => + Effect.fail(failure).pipe( + withContext(`restarting the composition also failed: ${restart.message}`), + ), + }), + ), + }), + // Signal aborts and test timeouts interrupt; the shared stack must not stay stopped. + Effect.onInterrupt(() => start(operation)), + ); + const testStack: EffectTestStack = { + stack, + services: byKind(members), + projectRoot, + checkpoint: (name) => + whileStopped("checkpoint", (current) => + current + .saveSnapshot(name, { scope: "instance" }) + .pipe(Effect.andThen(Ref.update(checkpoints, (names) => new Set([...names, name])))), + ), + reset: (name) => + Ref.get(checkpoints).pipe( + Effect.flatMap((names) => + names.has(name) + ? whileStopped("reset", (current) => + current.resetData.pipe( + withContext("the database data may have been partially reset"), + Effect.andThen( + current.restoreSnapshot(name, { scope: "instance" }).pipe( + Effect.flatMap((restored) => + restored + ? Effect.void + : Effect.fail( + new StackError({ + operation: "reset", + message: `Checkpoint ${name} is missing`, + }), + ), + ), + withContext( + `the database data was reset without restoring checkpoint ${name}`, + ), + ), + ), + ), + ) + : Effect.fail( + new StackError({ + operation: "reset", + message: `No checkpoint named ${name}; the database data was not reset`, + }), + ), + ), + ), + }; + return testStack; + }, + Effect.mapError(testFailure("test-stack")), +); + +const decodeCreation = (creation: unknown) => + Schema.decodeUnknownEffect(CreationSchema)(creation).pipe(Effect.mapError(testFailure("config"))); +const creationJson = Schema.toCodecJson(CreationSchema); +const decodePlainCreation = (creation: unknown) => + Schema.decodeUnknownEffect(creationJson)(creation).pipe(Effect.mapError(testFailure("config"))); + +/** Creates, composes and readies a session stack that the enclosing scope destroys. */ +export const makeTestStack = = readonly ["database"]>( + options: TestStackOptions = {}, +) => make, TestServiceKind>(options, decodeCreation); + +/** Creates, composes and readies a session stack for `await using`. */ +export const createTestStack = < + const S extends ReadonlyArray = readonly ["database"], +>( + options: TestStackOptions = {}, + callOptions?: CallOptions, +): Promise, TestServiceKind>> => + acquire( + make, TestServiceKind>(options, decodePlainCreation), + callOptions, + ).then(({ value, client }) => { + const adapter = stackAdapter(client); + return { + stack: adapter.stack(value.stack), + services: adapter.services, TestServiceKind>(value.services), + projectRoot: value.projectRoot, + checkpoint: (name, runOptions) => client.run(value.checkpoint(name), runOptions), + reset: (name, runOptions) => client.run(value.reset(name), runOptions), + [Symbol.asyncDispose]: client.close, + }; + }); diff --git a/packages/stack/tests/test-stack-client.ts b/packages/stack/tests/test-stack-client.ts new file mode 100644 index 0000000000..01e76a5b46 --- /dev/null +++ b/packages/stack/tests/test-stack-client.ts @@ -0,0 +1,58 @@ +/* oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch -- This fixture exercises the Promise testing API as a non-Effect consumer does. */ +import { discover, postgres, type Stack } from "../src/index.ts"; +import { createTestStack } from "../src/testing.ts"; + +const check = (condition: boolean, message: string) => { + if (!condition) throw new Error(message); +}; + +const sql = async (stack: Stack, databaseUrl: string, command: string) => { + const errors: Array = []; + const result = await stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "--set", "ON_ERROR_STOP=1", "--command", command], + stdout: () => {}, + stderr: (bytes) => { + errors.push(new TextDecoder().decode(bytes)); + }, + }); + check(result.exitCode === 0, `psql failed: ${errors.join("")}`); +}; + +const readRows = async (restUrl: string) => { + const response = await fetch(`${restUrl}/checkpoint_rows?select=value&order=value`); + const body = await response.text(); + check(response.status === 200, `REST read failed with ${response.status}: ${body}`); + return JSON.stringify(JSON.parse(body)); +}; + +const stateRoot = process.argv[2]; +if (stateRoot === undefined) throw new Error("Missing fixture state root"); + +let disposed: { readonly stackId: string; readonly projectRoot: string } | undefined; +{ + await using test = await createTestStack({ services: ["database", "rest"], stateRoot }); + const registered = await discover({ stateRoot }); + check( + registered.some((entry) => entry.definition.id === test.stack.id), + "Test stack is not registered under the fixture state root", + ); + disposed = { stackId: test.stack.id, projectRoot: test.projectRoot }; + const { databaseUrl } = await test.services.database.credentials({ from: "runtime" }); + const { url: restUrl } = await test.services.rest.credentials(); + if (databaseUrl === undefined || restUrl === undefined) throw new Error("Endpoints missing"); + await sql( + test.stack, + databaseUrl, + "CREATE TABLE public.checkpoint_rows (value text NOT NULL); GRANT SELECT ON public.checkpoint_rows TO anon; INSERT INTO public.checkpoint_rows VALUES ('seeded');", + ); + await test.checkpoint("seeded"); + await sql(test.stack, databaseUrl, "INSERT INTO public.checkpoint_rows VALUES ('scratch');"); + const beforeReset = await readRows(restUrl); + check(beforeReset === '[{"value":"scratch"},{"value":"seeded"}]', `Before reset: ${beforeReset}`); + + await test.reset("seeded"); + + const afterReset = await readRows(restUrl); + check(afterReset === '[{"value":"seeded"}]', `After reset: ${afterReset}`); +} +process.stdout.write(`${JSON.stringify(disposed)}\n`); From 2547ce5d4b6d826c2199bf46a5217d8da38752a5 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 29 Sep 2026 07:37:59 +0000 Subject: [PATCH 23/71] fix(stack): group database helper containers with their stack (#6870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** This builds on supabase/cli#6867, which groups a stack's containers as one compose project in Docker Desktop and OrbStack. The database storage helper containers (`supabase-db-helper-*`) were still left outside that group. They now join their stack's compose project as the `database-helper` service. Stacked on supabase/cli#6867; merge that first. ### Before ```mermaid flowchart LR G["Compose project supabase-myapp-…"] --> S["studio, rest, database …"] H["supabase-db-helper-…"] --> U["Ungrouped in Docker Desktop/OrbStack"] ``` ### After ```mermaid flowchart LR G["Compose project supabase-myapp-…"] --> S["studio, rest, database …"] G --> H["supabase-db-helper-…
service: database-helper"] ``` ### What changed - A shared `composeProjectFor` in `runtime/ContainerName.ts` derives the compose project for both the stack's service containers and its helpers. - The database storage receives the project folder name. Both the per-instance helper and the shared volume helper get `com.docker.compose.project` and `com.docker.compose.service=database-helper`. - Helper names and existing `com.supabase.*` labels are unchanged. Helpers carry no `com.supabase.service` label, so service log collectors skip them. ## Linked issue None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- packages/stack/src/runtime/ContainerName.ts | 19 +++++--- .../src/services/Database.integration.test.ts | 43 +++++++++++++++++++ packages/stack/src/services/Database.ts | 1 + .../src/storage/DockerDatabaseStorage.ts | 10 +++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/runtime/ContainerName.ts b/packages/stack/src/runtime/ContainerName.ts index 13c845ef42..a506ac8a27 100644 --- a/packages/stack/src/runtime/ContainerName.ts +++ b/packages/stack/src/runtime/ContainerName.ts @@ -15,6 +15,14 @@ const composeSegment = (value: string): string => .replaceAll(/[^a-z0-9_-]+/gu, "-") .slice(0, 40); +/** Groups a stack's containers, helpers included, as one compose project. */ +export const composeProjectFor = (stackId: string, project: string | undefined): string => + [ + "supabase", + project === undefined ? "stack" : composeSegment(project) || "stack", + stackId.slice(0, 12).toLowerCase(), + ].join("-"); + /** Names a container and sets compose grouping labels; one-shots get a `-task` segment. */ export const identifyContainer = (spec: ContainerIdentityInput, token: string, oneOff: boolean) => { const project = spec.project === undefined ? undefined : nameSegment(spec.project); @@ -28,10 +36,9 @@ export const identifyContainer = (spec: ContainerIdentityInput, token: string, o ] .filter((segment): segment is string => segment !== undefined && segment.length > 0) .join("-"); - const composeProject = [ - "supabase", - spec.project === undefined ? "stack" : composeSegment(spec.project) || "stack", - spec.stackId.slice(0, 12).toLowerCase(), - ].join("-"); - return { name, composeProject, composeService: service ?? "task" }; + return { + name, + composeProject: composeProjectFor(spec.stackId, spec.project), + composeService: service ?? "task", + }; }; diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index 3b2cc63541..08afa7397d 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -460,6 +460,49 @@ describe("database component", { timeout: 180_000 }, () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + it.live("groups the database and its storage helper under one compose project", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const stackId = "stack-compose-group"; + const root = yield* makeDockerDatabaseRoot("stack-database-group-", stackId); + const database = yield* makeDatabase({ + stackId, + instanceId: "database", + project: "my.app", + root, + cacheRoot: artifactCacheRoot, + runtime: "docker", + }); + const service = yield* makeService(database.definition, { + id: "database:group", + config, + }); + yield* service.start; + yield* service.ready; + const listed = yield* runDocker([ + "ps", + "--filter", + `label=com.supabase.stack-root=${path.resolve(root)}`, + "--format", + '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}', + ]); + const rows = listed.output + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.trim().split("|")); + expect(rows.some(([name]) => name?.startsWith("supabase-db-helper-"))).toBe(true); + expect(new Set(rows.map(([, project]) => project))).toEqual( + new Set(["supabase-my-app-stack-compos"]), + ); + expect(new Set(rows.map(([, , group]) => group))).toEqual( + new Set(["database", "database-helper"]), + ); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + it.live("shuts PostgreSQL down fast while a client stays connected across stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 4f4b52017c..943cd9958e 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -521,6 +521,7 @@ export const makeDatabase = ( runtime: options.runtime, stackId: String(options.stackId), instanceId: options.instanceId, + ...(options.project === undefined ? {} : { project: options.project }), instanceRoot, root: options.root, cacheRoot: options.cacheRoot, diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 26c80142b1..5bf4fcec34 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -16,6 +16,7 @@ import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/u import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; import { failureMessage } from "../internal/failure-message.ts"; import type { ContainerRuntime } from "../runtime/Container.ts"; +import { composeProjectFor } from "../runtime/ContainerName.ts"; import type { DatabaseRuntime } from "../services/Database.ts"; import { DatabaseSnapshotError, @@ -116,6 +117,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") readonly runtime: DatabaseRuntime; readonly stackId: string; readonly instanceId: string; + readonly project?: string; readonly instanceRoot: string; readonly root: string; readonly cacheRoot: string; @@ -128,6 +130,12 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }): Effect.Effect => Effect.gen(function* () { const markerPath = options.path.join(options.instanceRoot, ".supabase-database-storage.json"); + const composeHelperLabels = [ + "--label", + `com.docker.compose.project=${composeProjectFor(options.stackId, options.project)}`, + "--label", + "com.docker.compose.service=database-helper", + ]; const stateRoot = options.path.dirname(options.path.dirname(options.root)); const dataNamespace = `instance-${options.stackId}-${options.instanceId}`; const hash = (value: string) => @@ -588,6 +596,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") `com.supabase.instance=${options.instanceId}`, "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, + ...composeHelperLabels, ...mountArgs(mounts), preparedImage, "/bin/sh", @@ -677,6 +686,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") `com.supabase.stack=${options.stackId}`, "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, + ...composeHelperLabels, ...mountArgs(mounts), preparedImage, "/bin/sh", From 66e92c71899da7b2cc1fdc53bae7d1fed972bf55 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 29 Sep 2026 08:18:40 +0000 Subject: [PATCH 24/71] fix(stack): keep Studio awake for 5 minutes after its last request (#6866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** The experimental stack stopped Studio after 60 seconds without requests. A Studio tab in the background sends no requests, so Studio (and the pg-meta and analytics services it depends on) were stopped almost every time the user switched away. The next click then had to cold-start all three. Studio now idles after 5 minutes. Every Studio request still resets the timer. Other lazy services keep 60 seconds. ### Before ```mermaid flowchart LR T["Studio tab in the background
(no requests)"] -->|60s| K["Studio, pg-meta, analytics stopped"] K -->|next click| W["~8s cold start of all three"] ``` ### After ```mermaid flowchart LR T["Studio tab in the background
(no requests)"] -->|"5 min"| K["Studio, pg-meta, analytics stopped"] R["Any Studio request"] -->|resets the 5 min timer| T ``` ### Why Studio's page keeps no connection open, and activity is counted per proxied request, so the stack can't tell that a browser tab is still open. A longer idle window for Studio covers the common "switch to the editor and come back" loop. pg-meta and analytics already can't sleep while Studio, which depends on them, is running, so they follow Studio's timer. ### What changed - The composition assigns Studio a 5-minute idle timeout; the other lazy services keep 60 seconds and Functions still has none. - A composition-level test proves that Studio and its pg-meta prerequisite survive past 60 seconds after the last request, then both stop once Studio's idle window passes. - The whole-stack policy scenario and the `experimental stack start` side-effects doc reflect the Studio timeout. ## Linked issue None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 3 +- .../experimental/stack/start/SIDE_EFFECTS.md | 10 +- packages/stack/README.md | 2 +- packages/stack/src/composition/Supabase.ts | 8 +- .../src/composition/Supabase.unit.test.ts | 168 ++++++++++++++++++ packages/stack/tests/whole-stack/scenarios.ts | 2 +- 6 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 packages/stack/src/composition/Supabase.unit.test.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 9845b95485..ccefb2bc10 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -211,7 +211,8 @@ Host listener assignment for `supabase stack` is documented in [Port intents](./ ## Service selection and shutdown With the current defaults, enabled non-database services with endpoints are lazy and stop after -60 seconds without traffic; Functions has no automatic idle stop. An active HTTP request keeps a +60 seconds without traffic, Studio after 5 minutes; Functions has no automatic idle stop. A service +that a running service depends on stays up until that dependent stops. An active HTTP request keeps a capability running; an idle HTTP keep-alive socket does not. Open WebSocket or TCP connections keep a capability running during idle periods. Use `supabase stack start --eager` to activate all enabled capabilities and disable automatic idle stops. A request arriving while a capability is diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index da985e7a39..4a9ff2bb38 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -49,10 +49,12 @@ root's home directory. Later commands that restrict the artifact cache and stack their owner keep that grant. Database is eager by default. Other services are lazy; traffic wakes them through their listeners. -Lazy services with idle policies stop after 60 seconds without traffic; Functions has no automatic -idle stop. `--eager` makes all selected services eager. Changes to activation policy take effect -after stopping and starting the stack, including when a later invocation omits an earlier `--eager` -flag. `--preparation` selects on-demand or background artifact preparation. +Lazy services with idle policies stop after 60 seconds without traffic, Studio after 5 minutes. A +service that a running service depends on, such as pg-meta for Studio, stays up until that +dependent stops. Functions has no automatic idle stop. `--eager` makes all selected services eager. +Changes to activation policy take effect after stopping and starting the stack, including when a +later invocation omits an earlier `--eager` flag. `--preparation` selects on-demand or background +artifact preparation. When Functions is selected, the CLI reads and validates `supabase/functions/.env`, ignoring reserved `SUPABASE_*` entries. `edge_runtime.secrets` overrides that file, while `functions..env` diff --git a/packages/stack/README.md b/packages/stack/README.md index 13d5880023..0eb6a641cf 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -114,7 +114,7 @@ const services = await stack.composition.supabase([ await stack.composition.start(); ``` -The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Functions are lazy without an idle timeout; other public services are lazy with a 60-second idle timeout. Services without public endpoints are eager. Pass `{ eager: true }` to start every member eagerly. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition. +The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Functions are lazy without an idle timeout; Studio is lazy with a 5-minute idle timeout; other public services are lazy with a 60-second idle timeout. Services without public endpoints are eager. Pass `{ eager: true }` to start every member eagerly. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition. Inputs that the composition binds or the owner fills from the stack credentials, such as REST's `databaseUrl` or a JWT secret, are optional in creation configuration. Starting or restarting an instance without a required input that was neither bound nor provided fails before any lifecycle change with a `ServiceError` whose `operation` is `"input"` and whose message names the input; a running instance keeps running. diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index 74a436099e..19fb3eeaf9 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -7,6 +7,12 @@ import type { SavedStack, StackKeysInput } from "../State.ts"; import { credentialInputNames } from "../host/Credentials.ts"; import { apiRoute, endpointNames, endpointPort } from "../host/Endpoints.ts"; +const DEFAULT_IDLE_MILLIS = 60_000; +/** Studio idles slower than its peers: a background tab shouldn't cold-start it every minute. */ +const STUDIO_IDLE_MILLIS = 300_000; +const idleMillisFor = (service: ServiceCreation["service"]): number => + service === "studio" ? STUDIO_IDLE_MILLIS : DEFAULT_IDLE_MILLIS; + const managedBindings: ReadonlyArray<{ readonly sourceKind: ServiceCreation["service"]; readonly sourceEndpoint: string; @@ -620,7 +626,7 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( return lazy ? creation.service === "functions" ? { id, activation: "lazy" as const } - : { id, activation: "lazy" as const, idleMillis: 60_000 } + : { id, activation: "lazy" as const, idleMillis: idleMillisFor(creation.service) } : { id, activation: "eager" as const }; }); yield* operations.configure({ diff --git a/packages/stack/src/composition/Supabase.unit.test.ts b/packages/stack/src/composition/Supabase.unit.test.ts new file mode 100644 index 0000000000..e4f692f074 --- /dev/null +++ b/packages/stack/src/composition/Supabase.unit.test.ts @@ -0,0 +1,168 @@ +import { expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, Redacted, Ref, Scope, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import * as Orchestrator from "../Orchestrator.ts"; +import { makeService, ServiceError } from "../Service.ts"; +import type { ServiceCreation } from "../services/Catalog.ts"; +import { makeSupabaseComposition, type SupabaseCompositionOperations } from "./Supabase.ts"; + +/** A registered instance with no runtime behavior beyond an immediate healthy start and stop. */ +const makeInstance = ( + orchestrator: Orchestrator.Interface, + id: string, + options: { + readonly inputs?: ReadonlyArray; + readonly outputs?: Readonly>>; + readonly hasEndpoint?: boolean; + } = {}, +) => + Effect.gen(function* () { + const core = yield* makeService>( + { + launch: () => + Effect.gen(function* () { + const exited = yield* Deferred.make>(); + return { + health: Effect.void, + exit: Deferred.await(exited), + stop: Deferred.succeed(exited, Exit.void).pipe(Effect.asVoid), + remove: Effect.void, + }; + }), + removeData: () => Effect.void, + }, + { id, config: {}, coordinate: orchestrator.admissionFor(id) }, + ); + const instance: Orchestrator.RegisteredInstance = { + id, + core, + startAt: (revision, inputs, wake, guard) => core.startAt(revision, inputs, wake, guard), + restart: (revision, inputs, config, guard) => core.restart(inputs, revision, guard), + bind: Effect.void, + close: Effect.void, + hasEndpoint: options.hasEndpoint ?? true, + inputs: options.inputs ?? [], + outputs: options.outputs ?? {}, + }; + yield* orchestrator.register(instance); + return instance; + }); + +/** Merges optional config bindings onto a creation; the config union can't express this generically. */ +const withValues = ( + creation: C, + values: Record, +): C => ({ ...creation, config: { ...creation.config, ...values } }) as C; + +const stopped = (instance: Orchestrator.RegisteredInstance) => + instance.core.observation.pipe( + Stream.filter((state) => state.lifecycle === "stopped" && state.currentOperation === undefined), + Stream.take(1), + Stream.runDrain, + ); + +it.live( + "keeps a studio member's pgmeta prerequisite awake past 60s, and sleeps both after studio idles", + () => + Effect.scoped( + Effect.gen(function* () { + const idFor = (service: ServiceCreation["service"]) => `${service}-id`; + const inputs: ReadonlyArray = [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("studio-idle-password"), + jwtSecret: Redacted.make("studio-idle-jwt-secret-with-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + { + service: "auth", + config: { jwtSecret: "studio-idle-jwt-secret-with-32-chars", jwtExpiry: 3600 }, + endpoints: { http: { port: "auto" } }, + }, + { service: "studio", config: {}, endpoints: { http: { port: "auto" } } }, + { service: "pgmeta", config: {}, endpoints: { http: { port: "auto" } } }, + ]; + const baseById = new Map(inputs.map((creation) => [idFor(creation.service), creation])); + const captured = yield* Ref.make(undefined); + const operations: SupabaseCompositionOperations = { + currentComposition: Effect.succeed({ members: [], dependencies: [] }), + get: () => Effect.die("get is unused"), + status: () => Effect.die("status is unused"), + create: (creation) => Effect.succeed({ id: idFor(creation.service), creation }), + destroy: () => Effect.die("destroy is unused"), + bind: () => Effect.void, + address: (id, endpoint, from) => Effect.succeed(`${from}://${id}/${endpoint}`), + output: (id, name) => Effect.succeed(`${name}::${id}`), + updateCreation: (id, values) => { + const base = baseById.get(id); + return base === undefined + ? Effect.die(`Unknown instance ${id}`) + : Effect.succeed({ id, creation: withValues(base, values) }); + }, + replaceCreation: () => Effect.die("replaceCreation is unused"), + configure: (configuration) => Ref.set(captured, configuration), + }; + yield* makeSupabaseComposition(operations, inputs); + const configuration = yield* Ref.get(captured); + if (configuration === undefined) return yield* Effect.die("Composition was not configured"); + + const studioId = idFor("studio"); + const pgmetaId = idFor("pgmeta"); + // pgmeta must be a declared prerequisite of studio for the dependent-blocks-sleep rule below to apply. + expect( + configuration.dependencies.some( + (dependency) => dependency.from === pgmetaId && dependency.to === studioId, + ), + ).toBe(true); + + const orchestrator = yield* Orchestrator.make(); + const database = yield* makeInstance(orchestrator, idFor("database"), { + outputs: { + authDatabaseUrl: Effect.succeed("postgres://auth"), + databaseUrl: Effect.succeed("postgres://pgmeta"), + }, + }); + yield* makeInstance(orchestrator, idFor("auth"), { inputs: ["databaseUrl"] }); + const pgmeta = yield* makeInstance(orchestrator, pgmetaId, { + inputs: ["databaseUrl"], + outputs: { url: Effect.succeed("http://pgmeta") }, + }); + const studio = yield* makeInstance(orchestrator, studioId, { + inputs: ["databaseUrl", "pgmetaUrl", "analyticsUrl", "functionsUrl"], + }); + yield* orchestrator.configure(configuration); + yield* orchestrator.startComposition; + + const requestScope = yield* Scope.make(); + yield* orchestrator.acquire(studioId).pipe(Scope.provide(requestScope)); + yield* TestClock.adjust("1 second"); + expect((yield* database.core.get).lifecycle).toBe("running"); + expect((yield* pgmeta.core.get).lifecycle).toBe("running"); + expect((yield* studio.core.get).lifecycle).toBe("running"); + + // The request finished; studio itself now idles on its own 5-minute timer. + yield* Scope.close(requestScope, Exit.void); + + // Well past pgmeta's own 60s idle mark but within studio's 5-minute window: studio + // isn't idle yet, so the dependent-blocks-sleep rule keeps pgmeta running too. + yield* TestClock.adjust("90 seconds"); + expect((yield* studio.core.get).lifecycle).toBe("running"); + expect((yield* pgmeta.core.get).lifecycle).toBe("running"); + + // One second before studio's 5-minute idle deadline, measured from the request. + yield* TestClock.adjust("208 seconds"); + expect((yield* studio.core.get).lifecycle).toBe("running"); + expect((yield* pgmeta.core.get).lifecycle).toBe("running"); + + const studioStopped = yield* stopped(studio).pipe(Effect.forkChild); + const pgmetaStopped = yield* stopped(pgmeta).pipe(Effect.forkChild); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(studioStopped); + yield* Fiber.join(pgmetaStopped); + }), + ).pipe(Effect.provide(TestClock.layer())), +); diff --git a/packages/stack/tests/whole-stack/scenarios.ts b/packages/stack/tests/whole-stack/scenarios.ts index 07e9bfd92b..5edcc136fa 100644 --- a/packages/stack/tests/whole-stack/scenarios.ts +++ b/packages/stack/tests/whole-stack/scenarios.ts @@ -56,7 +56,7 @@ const assertDefaultPolicy = (fixture: WholeStack) => expect(member.idleMillis).toBeUndefined(); } else { expect(member.activation).toBe("lazy"); - expect(member.idleMillis).toBe(60_000); + expect(member.idleMillis).toBe(name === "studio" ? 300_000 : 60_000); } } }); From ff14e91a715b0beb248a5119468ff5483f91f744 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 29 Sep 2026 08:19:04 +0000 Subject: [PATCH 25/71] fix(stack): reduce native stack friction in agent sandboxes (#6856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR** Running `supabase@beta`'s native stack inside agent sandboxes hit several first-run failures that were hard to diagnose or avoidable. Native artifact failures now name every checksum source and mirror tried. `stack stop` no longer fails when the sandbox never reaps the exited owner. A missing Docker gets an actionable suggestion. ### Before ```mermaid flowchart LR A[stack start
native] --> C{"checksum:
GitHub, then ghcr"} C -->|both blocked| F1["❌ Unable to download
.../SHA256SUMS"] C -->|ok| S[services start] S --> P[stack stop] P -->|owner never reaped| F3["❌ process N is still running"] D[stack start
--runtime docker] -->|no docker| F4["❌ spawn docker ENOENT"] ``` ### After ```mermaid flowchart LR A[stack start
native] --> C{"checksum:
GitHub, then ghcr"} C -->|both blocked| F1["❌ every source with
its HTTP status or errno"] C -->|ok| S[services start] S --> P[stack stop] P -->|owner never reaped| OK["✅ accepted as exited
after the exit wait"] D[stack start
--runtime docker] -->|no docker| F4["❌ same error
+ install Docker or
use --runtime native"] ``` ### Why - **Artifact failures dropped their cause.** Only the primary source's error survived, as `Unable to prepare database artifact: Unable to download .../SHA256SUMS`, with no HTTP status or network error and no trace of the ghcr or S3 attempts. - **`stack stop` failed after a successful shutdown.** The detached owner is reparented to PID 1. Some sandbox init processes never reap it, so it stays a zombie, which still answers `kill(pid, 0)`. The stop then failed with `Owner shutdown acknowledgement completed, but process N is still running`. - **Root sandboxes without a detected provider** failed with `PostgreSQL cannot be run as root`, with no hint on how to proceed when no non-root account exists. - **Missing Docker** surfaced as a raw `spawn docker` error, although the stack already classifies it as `runtime-unavailable`. ### What changed - When every checksum source or archive mirror fails, the error lists each one with its full failure chain (HTTP status or error code). It keeps the artifact's service and version. ADR 0026 is updated for this. A shared `errorChainMessage` (moved out of `Orchestrator.causeMessage`) unwraps nested causes. The S3 bucket remains an archive mirror only, not a checksum authority. - The owner-exit probe takes `FileSystem` (`ownerExitProbe(fs)`) and, on Linux, reports a `Z`/`X` owner as a zombie. The exit wait keeps retrying while the owner is a zombie, so a reaping parent can clear it. An owner still unreaped when the wait ends is accepted as exited. - A Modal Sandbox (`MODAL_SANDBOX_ID` present) triggers the existing non-root PostgreSQL account selection. The root-refusal suggestion now shows how to create an account. - `StackError.reason` gains `runtime-unavailable`, set when the container engine is unreachable. `stack start` uses it to suggest installing or starting Docker. It adds `--runtime native` only when creating a new stack on a platform that supports native. - Updated `stack start` `SIDE_EFFECTS.md`, `packages/stack/README.md`, `docs/stack-commands.md`, and ADR 0026. ## Linked issue No linked issue; found while running the beta CLI in agent sandboxes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 4 +- .../experimental/stack/start/SIDE_EFFECTS.md | 5 +- .../experimental/stack/start/start.handler.ts | 38 +++++- .../stack/start/start.integration.test.ts | 5 + docs/adr/0026-slim-artifact-mirrors.md | 2 +- packages/stack/README.md | 2 +- packages/stack/src/HostProcess.ts | 112 ++++++++++++------ packages/stack/src/HostProcess.unit.test.ts | 66 ++++++++++- packages/stack/src/Orchestrator.ts | 17 +-- packages/stack/src/Rpc.ts | 9 +- ...ost.container-shutdown.integration.test.ts | 18 ++- packages/stack/src/effect.ts | 10 +- packages/stack/src/internal/error-message.ts | 14 +++ .../src/preparation/SlimServicesSource.ts | 86 ++++++++------ .../slim-services.integration.test.ts | 18 ++- packages/stack/src/runtime/postgres-user.ts | 12 +- .../src/runtime/postgres-user.unit.test.ts | 19 ++- .../stack/tests/compiled-dispatch-fixture.ts | 8 +- 18 files changed, 339 insertions(+), 106 deletions(-) create mode 100644 packages/stack/src/internal/error-message.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index ccefb2bc10..b18587fe99 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -27,7 +27,9 @@ when its engine answers, and otherwise native on Linux x64/arm64 and macOS arm64 platforms without a reachable engine, the command fails and asks you to start Docker or Podman. The selected runtime is saved with the stack and reused without probing; when auto selection skips Docker, the command prints a notice saying so. To switch, destroy the stack or choose a different -`--stack` name. Project stacks created by database commands, and shadow stacks +`--stack` name. When an explicit `--runtime docker` or a saved Docker stack cannot reach Docker, +the failure asks you to install or start it, and also suggests `--runtime native` for a new stack +on platforms that support native. Project stacks created by database commands, and shadow stacks created without a project stack, use the same selection. `supabase stack prepare` downloads or pulls artifacts for the selected stack without starting diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 4a9ff2bb38..9eabf076fd 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -41,8 +41,11 @@ probe is bounded by 10 seconds. Without a reachable engine on other platforms, t asks the user to start Docker or Podman. When auto selection skips Docker, an info line names the saved Podman or native runtime and how to switch to Docker. An existing stack keeps its saved runtime and runs no probe. Explicit `--runtime docker`, `podman`, or `native` has no fallback. +When an explicit or saved Docker runtime is unreachable, the reported failure suggests starting +Docker, and `--runtime native` for a new stack on platforms that support native. + Native startup refuses root because PostgreSQL `initdb` cannot run as root, unless a Claude Code -sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs +or Modal Sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs as that user: the CLI chowns the instance data, root key, socket directory, and the cached bundle's `pgsodium_getkey.sh` to it, and adds traverse-only `o+x` to their parent directories, including root's home directory. Later commands that restrict the artifact cache and stack state roots to diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 5a6053a0da..e590ff0572 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,9 +1,11 @@ +import { defaultRuntime } from "@supabase/stack/internal/artifacts"; import { endpointReports } from "../stack-endpoints.format.ts"; import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; import { automaticRuntimeNotice, selectStackRuntime, } from "../../../../command-internal/stack-runtime.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { Effect, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; import { resolveNativePostgresUser, @@ -95,6 +97,35 @@ const stackError = ( }); }; +// A saved stack keeps its runtime, so only a new stack can switch to native. +const dockerUnavailableSuggestion = ( + runtimeInfo: { readonly platform: string; readonly arch: string }, + creating: boolean, +) => + creating && defaultRuntime({ os: runtimeInfo.platform, arch: runtimeInfo.arch }) === "native" + ? "Docker CLI or daemon isn't reachable. Install or start Docker, or run with --runtime native." + : "Docker CLI or daemon isn't reachable. Install or start Docker."; + +const stackAcquireError = ( + cause: StackError, + runtimeContext: { + readonly selectedRuntime: "native" | "docker" | "podman"; + readonly runtime: { readonly platform: string; readonly arch: string }; + readonly creating: boolean; + }, +) => { + const base = stackError(cause); + if (cause.reason !== "runtime-unavailable" || runtimeContext.selectedRuntime !== "docker") + return base; + return new StackCommandStartError({ + reason: "runtime", + message: base.message, + ...(base.detail === undefined ? {} : { detail: base.detail }), + suggestion: dockerUnavailableSuggestion(runtimeContext.runtime, runtimeContext.creating), + cause: base.cause, + }); +}; + const loadStartConfig = (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.gen(function* () { const config = yield* loadStackConfig(projectRoot); @@ -193,6 +224,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags runtime: flags.runtime, }) .pipe(Effect.mapError(mapTargetError)); + const runtime = yield* RuntimeInfo; const selectedRuntime = yield* selectStackRuntime(target.runtime).pipe( Effect.mapError( (error) => @@ -247,7 +279,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ), ), - ).pipe(Effect.mapError(stackError)); + ).pipe( + Effect.mapError((cause) => + stackAcquireError(cause, { selectedRuntime, runtime, creating: target.id === undefined }), + ), + ); const runtimeNotice = target.id === undefined ? automaticRuntimeNotice(target.runtime, selectedRuntime) : undefined; if (runtimeNotice !== undefined) yield* output.info(runtimeNotice); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index e07978b65a..0e9a760b1b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1107,6 +1107,11 @@ describe("experimental stack start", () => { Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), ); expect(error.message).toContain("Cannot connect to the Docker daemon"); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.reason).toBe("runtime"); + expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); + } expect(output.stderrText).not.toContain("Failed to stop"); const stacks = yield* StackApi.pipe( diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index 31b429dcc1..cdcc4fb4ca 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -19,7 +19,7 @@ ECR Public tags are always mutable; `ecr-public create-repository` has no immuta A public S3 bucket on `*.amazonaws.com` holds native archives for hosts that cannot reach GitHub Releases ([infra/cli-artifacts](../../infra/cli-artifacts/README.md)). It is not a checksum authority. The stack tries the GitHub Release first and falls back to that bucket. The expected checksum comes from the release `SHA256SUMS` or, when that is blocked, from the archive layer of the `:version-native-` artifact on GHCR. An archive from either host is accepted only when it matches. -The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing fallback, for an image or a native archive, still reports the primary's original error. +The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing image fallback still reports the primary's original error. When every native checksum source or archive mirror fails, the error names each source with its failure. ## Follow-up diff --git a/packages/stack/README.md b/packages/stack/README.md index 0eb6a641cf..f756b8a015 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -85,7 +85,7 @@ Omitted database `jwtSecret` and `rootKey` inputs use the shared local-developme as `DEFAULT_LOCAL_JWT_SECRET` and `DEFAULT_POSTGRES_ROOT_KEY`. Explicit values override these defaults. The effective root key is supplied through a stack-owned file for both native and container runtimes. -Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code), or `SUPABASE_NATIVE_POSTGRES_USER=` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. +Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code, Modal Sandbox), or `SUPABASE_NATIVE_POSTGRES_USER=` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. Native PostgreSQL listens only on its socket and reads a stack-generated HBA file, written to the socket directory on every launch, instead of `PGDATA/pg_hba.conf`. It trusts `supabase_admin`, including through the proxied loopback database port, and requires `scram-sha-256` passwords from every other role. diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 3ff1146085..46b60d3820 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -92,6 +92,7 @@ type HostFailureReason = | "runtime-unavailable" | "invalid-owner-pid" | "owner-exit-pending" + | "owner-exit-zombie" | "owner-exit-probe"; const HostIdentity = Schema.Struct({ projectRoot: Schema.String, @@ -572,37 +573,62 @@ export const launchHost = Effect.fn("HostProcess.launchHost")(function* ( export type OwnerExitProbeResult = | { readonly state: "absent" } | { readonly state: "present" } + | { readonly state: "zombie" } | { readonly state: "inconclusive"; readonly code: "EPERM" }; export type OwnerExitProbe = (pid: number) => Effect.Effect; -/** Probes the captured owner PID with signal 0. */ -const probeOwnerExit: OwnerExitProbe = Effect.fn("HostProcess.probeOwnerExit")(function* (pid) { - return yield* Effect.try({ - try: () => { - process.kill(pid, 0); - return { state: "present" } as const; - }, - catch: (cause) => - new HostProcessError({ - operation: "shutdown-exit", - message: `Shutdown acknowledged, but probing owner process ${pid} failed: ${String(cause)}`, - reason: "owner-exit-probe", - cause, +const zombieProcStates = new Set(["Z", "X"]); + +/** The state field follows the last `)`, because `comm` may itself contain spaces and parens. */ +const procStatState = (stat: string): string | undefined => + stat + .slice(stat.lastIndexOf(")") + 1) + .trim() + .split(/\s+/u)[0]; + +/** Probes the owner PID with signal 0 and, on Linux, reports a `Z` or `X` owner as a zombie. */ +export const ownerExitProbe = ( + fs: FileSystem.FileSystem, + platform: string = process.platform, +): OwnerExitProbe => + Effect.fn("HostProcess.probeOwnerExit")(function* (pid) { + const signalResult = yield* Effect.try({ + try: () => { + process.kill(pid, 0); + return { state: "present" } as const; + }, + catch: (cause) => + new HostProcessError({ + operation: "shutdown-exit", + message: `Shutdown acknowledged, but probing owner process ${pid} failed: ${String(cause)}`, + reason: "owner-exit-probe", + cause, + }), + }).pipe( + Effect.catch((failure): Effect.Effect => { + const code = causeCode(failure.cause); + if (code === "ESRCH") return Effect.succeed({ state: "absent" } as const); + if (code === "EPERM") return Effect.succeed({ state: "inconclusive", code } as const); + return Effect.fail(failure); }), - }).pipe( - Effect.catch((failure): Effect.Effect => { - const code = causeCode(failure.cause); - if (code === "ESRCH") return Effect.succeed({ state: "absent" } as const); - if (code === "EPERM") return Effect.succeed({ state: "inconclusive", code } as const); - return Effect.fail(failure); - }), - ); -}); + ); + if (signalResult.state !== "present" || platform !== "linux") return signalResult; + const state = yield* fs.readFileString(`/proc/${pid}/stat`).pipe( + Effect.map(procStatState), + Effect.orElseSucceed(() => undefined), + ); + return state !== undefined && zombieProcStates.has(state) + ? ({ state: "zombie" } as const) + : signalResult; + }); -/** Waits until the captured owner PID is absent from the process table. */ +/** + * Waits until the captured owner PID is absent from the process table. An owner still a zombie when + * the wait ends has exited but was never reaped, as under a sandbox PID 1 that does not reap orphans. + */ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(function* ( pid: number, - probe: OwnerExitProbe = probeOwnerExit, + probe: OwnerExitProbe, ) { if (!Number.isSafeInteger(pid) || pid <= 0) return yield* error( @@ -611,25 +637,43 @@ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(functi "invalid-owner-pid", ); const check = probe(pid).pipe( - Effect.flatMap((result) => - result.state === "absent" - ? Effect.void - : Effect.fail( + Effect.flatMap((result) => { + switch (result.state) { + case "absent": + return Effect.void; + case "zombie": + return Effect.fail( + error("shutdown-exit", `Owner process ${pid} is not reaped yet`, "owner-exit-zombie"), + ); + case "present": + return Effect.fail( error( "shutdown-exit", - result.state === "present" - ? `Owner shutdown acknowledgement completed, but process ${pid} is still running` - : `Owner shutdown acknowledgement completed, but process ${pid} is inaccessible (${result.code}); exit is inconclusive`, + `Owner shutdown acknowledgement completed, but process ${pid} is still running`, "owner-exit-pending", ), - ), - ), + ); + case "inconclusive": + return Effect.fail( + error( + "shutdown-exit", + `Owner shutdown acknowledgement completed, but process ${pid} is inaccessible (${result.code}); exit is inconclusive`, + "owner-exit-pending", + ), + ); + } + }), ); return yield* check.pipe( Effect.retry({ schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })), - while: (failure) => failure.reason === "owner-exit-pending", + while: (failure) => + failure.reason === "owner-exit-pending" || failure.reason === "owner-exit-zombie", }), + Effect.catchIf( + (failure) => failure.reason === "owner-exit-zombie", + () => Effect.void, + ), ); }); diff --git a/packages/stack/src/HostProcess.unit.test.ts b/packages/stack/src/HostProcess.unit.test.ts index 96a8c78067..6ac07fd48e 100644 --- a/packages/stack/src/HostProcess.unit.test.ts +++ b/packages/stack/src/HostProcess.unit.test.ts @@ -1,10 +1,11 @@ import { expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Ref } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, PlatformError, Ref } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { HostProcessError, type OwnerExitProbe, type OwnerExitProbeResult, + ownerExitProbe, waitForOwnerExit, } from "./HostProcess.ts"; @@ -119,3 +120,66 @@ it.effect("rejects an invalid owner PID before probing", () => expect(yield* Ref.get(attempts)).toBe(0); }), ); + +// The test process itself answers signal 0, so /proc content alone decides the result. +const statOf = (stat: string) => + FileSystem.makeNoop({ readFileString: () => Effect.succeed(stat) }); + +it.effect("reports a zombie or dead owner on Linux", () => + Effect.gen(function* () { + const probe = (stat: string) => ownerExitProbe(statOf(stat), "linux")(process.pid); + const pid = process.pid; + expect(yield* probe(`${pid} (node) Z 1 1 1 0 -1 4194560`)).toEqual({ state: "zombie" }); + expect(yield* probe(`${pid} (node) X 1 1 1 0 -1 4194560`)).toEqual({ state: "zombie" }); + expect(yield* probe(`${pid} (my ) weird) proc) Z 1 1 1 0 -1 4194304`)).toEqual({ + state: "zombie", + }); + expect(yield* probe(`${pid} (node) S 1 1 1 0 -1 4194560`)).toEqual({ state: "present" }); + }), +); + +it.effect("waits for a zombie owner to be reaped", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const probe: OwnerExitProbe = () => + Ref.getAndUpdate(attempts, (count) => count + 1).pipe( + Effect.map((count): OwnerExitProbeResult => + count < 2 ? { state: "zombie" } : { state: "absent" }, + ), + ); + const fiber = yield* waitForOwnerExit(123, probe).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("100 millis"); + yield* Fiber.join(fiber); + expect(yield* Ref.get(attempts)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("accepts an owner that is still an unreaped zombie when the wait ends", () => + Effect.gen(function* () { + const probe: OwnerExitProbe = () => Effect.succeed({ state: "zombie" }); + const fiber = yield* waitForOwnerExit(123, probe).pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("6 seconds"); + expect(Exit.isSuccess(yield* Fiber.join(fiber))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("keeps the signal result when /proc is unreadable or the platform is not Linux", () => + Effect.gen(function* () { + const unreadable = FileSystem.makeNoop({ + readFileString: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + }), + ), + }); + expect(yield* ownerExitProbe(unreadable, "linux")(process.pid)).toEqual({ state: "present" }); + const zombie = statOf(`${process.pid} (node) Z 1 1 1 0 -1 4194560`); + expect(yield* ownerExitProbe(zombie, "darwin")(process.pid)).toEqual({ state: "present" }); + }), +); diff --git a/packages/stack/src/Orchestrator.ts b/packages/stack/src/Orchestrator.ts index 839d2d0af1..4c136d4269 100644 --- a/packages/stack/src/Orchestrator.ts +++ b/packages/stack/src/Orchestrator.ts @@ -21,6 +21,7 @@ import { type ServiceObservation, } from "./Service.ts"; import type { ServiceAdmission } from "./Service.ts"; +import { errorChainMessage } from "./internal/error-message.ts"; export type OrchestratorOperation = | "start" @@ -47,21 +48,7 @@ export class OrchestratorError extends Data.TaggedError("OrchestratorError")<{ /** Summarizes a failure cause as one line per error, including its nested error causes. */ export const causeMessage = (cause: Cause.Cause): string => - Cause.prettyErrors(cause) - .map((error) => { - const parts: Array = []; - const visited = new Set(); - let current: unknown = error; - while (current instanceof Error && !visited.has(current)) { - visited.add(current); - const text = current.message || current.name; - // Wrappers often copy their cause's message; keep the chain but not the repeat. - if (parts.at(-1) !== text) parts.push(text); - current = current.cause; - } - return parts.join(": "); - }) - .join("; ") || "interrupted"; + Cause.prettyErrors(cause).map(errorChainMessage).join("; ") || "interrupted"; type CoreObservation = ServiceObservation; diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 6e851424af..9b296509c4 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -19,10 +19,13 @@ export class StackError extends Schema.TaggedError()("StackError", { message: Schema.String, outcomes: Schema.optionalKey(Schema.Array(Outcome)), /** - * Why the client could not use an owner: none serves the stack (`owner-unavailable`), or one of - * another release does (`release-mismatch`). + * Why the client could not use an owner: none serves the stack (`owner-unavailable`), one of + * another release does (`release-mismatch`), or its container engine is unreachable + * (`runtime-unavailable`). */ - reason: Schema.optionalKey(Schema.Literals(["owner-unavailable", "release-mismatch"])), + reason: Schema.optionalKey( + Schema.Literals(["owner-unavailable", "release-mismatch", "runtime-unavailable"]), + ), }) {} /** Maps an owner failure to the RPC error, preserving per-member composition outcomes. */ diff --git a/packages/stack/src/StackHost.container-shutdown.integration.test.ts b/packages/stack/src/StackHost.container-shutdown.integration.test.ts index 9c57d139c5..4c0971f97c 100644 --- a/packages/stack/src/StackHost.container-shutdown.integration.test.ts +++ b/packages/stack/src/StackHost.container-shutdown.integration.test.ts @@ -3,7 +3,13 @@ import { expect, it } from "@effect/vitest"; import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as State from "./State.ts"; -import { hasReason, launchHost, ownerClient, waitForOwnerExit } from "./HostProcess.ts"; +import { + hasReason, + launchHost, + ownerClient, + ownerExitProbe, + waitForOwnerExit, +} from "./HostProcess.ts"; import { makeContainerRuntime } from "./runtime/Container.ts"; import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; import { shutdownOwner } from "../tests/owner.ts"; @@ -119,7 +125,7 @@ it.live.skipIf(process.platform === "win32")( const signalAndWait = (endpoint: { pid: number }, signal: NodeJS.Signals) => Effect.sync(() => process.kill(endpoint.pid, signal)).pipe( Effect.andThen( - waitForOwnerExit(endpoint.pid).pipe( + waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.retry({ while: hasReason("owner-exit-pending") }), Effect.timeout("30 seconds"), ), @@ -226,7 +232,9 @@ it.live.skipIf(process.platform === "win32")( activeA = endpointA2; stoppedA = false; yield* shutdownOwner(accessA2, true); - yield* waitForOwnerExit(endpointA2.pid).pipe(Effect.timeout("15 seconds")); + yield* waitForOwnerExit(endpointA2.pid, ownerExitProbe(fs)).pipe( + Effect.timeout("15 seconds"), + ); stoppedA = true; activeA = undefined; expect(yield* containers(stackId, dataA), "destroy removes A containers").toEqual([]); @@ -237,7 +245,9 @@ it.live.skipIf(process.platform === "win32")( activeB = endpointB2; stoppedB = false; yield* shutdownOwner(accessB2, true); - yield* waitForOwnerExit(endpointB2.pid).pipe(Effect.timeout("15 seconds")); + yield* waitForOwnerExit(endpointB2.pid, ownerExitProbe(fs)).pipe( + Effect.timeout("15 seconds"), + ); stoppedB = true; activeB = undefined; expect(yield* containers(stackId, dataB), "destroy removes B containers").toEqual([]); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index c9642732f7..f8cb04d449 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -27,6 +27,7 @@ import { launchHost, observeHost, ownerClient, + ownerExitProbe, shutdownHost, waitForOwnerExit, type HostAccess, @@ -128,7 +129,9 @@ const failure = (operation: string, cause: unknown): StackError => ? { reason: "owner-unavailable" as const } : hasReason("release-mismatch")(cause) ? { reason: "release-mismatch" as const } - : {}), + : hasReason("runtime-unavailable")(cause) + ? { reason: "runtime-unavailable" as const } + : {}), }); type Kind = ServiceCreation["service"]; @@ -391,6 +394,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( >(), ); const crypto = yield* Crypto.Crypto; + const fs = yield* FileSystem.FileSystem; const handleScope = yield* Scope.Scope; const session = saved.lifetime === "session"; const launchOptions = { ...locations, stackId: saved.id, lifeline: session }; @@ -592,7 +596,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( onSome: (cause) => failure(operation, cause), }); if (!destroy) return yield* shutdownFailure; - const exitResult = yield* waitForOwnerExit(endpoint.pid).pipe( + const exitResult = yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), Effect.exit, ); @@ -611,7 +615,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( } return yield* shutdownFailure; } - yield* waitForOwnerExit(endpoint.pid).pipe( + yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), ); return { runtimeCleanup: "complete" } as const; diff --git a/packages/stack/src/internal/error-message.ts b/packages/stack/src/internal/error-message.ts new file mode 100644 index 0000000000..f770b806d5 --- /dev/null +++ b/packages/stack/src/internal/error-message.ts @@ -0,0 +1,14 @@ +/** Walks an error's `.cause` chain, joining each distinct message so nested detail is not lost. */ +export const errorChainMessage = (error: unknown): string => { + const parts: Array = []; + const visited = new Set(); + let current: unknown = error; + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + const text = current.message || current.name; + // Wrappers often copy their cause's message; keep the chain but not the repeat. + if (parts.at(-1) !== text) parts.push(text); + current = current.cause; + } + return parts.join(": "); +}; diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 90b57330a5..f4453347b6 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientError } from "effect/unstable/http"; import { createZstdDecompress } from "node:zlib"; import { createHash } from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { errorChainMessage } from "../internal/error-message.ts"; import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; import { PreparationError } from "./Errors.ts"; @@ -34,7 +35,7 @@ export interface SlimServicesArtifact { readonly target: "darwin-arm64" | "linux-amd64" | "linux-arm64"; readonly archive: "tar.zst"; readonly assetName: string; - /** Checksum authorities, tried in order. Download mirrors never supply their own checksum. */ + /** Checksum authorities, tried in order; a mirror's own checksum file counts only when listed here. */ readonly checksums: readonly [ SlimServicesChecksumSource, ...ReadonlyArray, @@ -224,41 +225,53 @@ const downloadToFile = Effect.fn("SlimServicesSource.downloadToFile")(function* }); }); -const fallbackDetail = (error: PreparationError): string => { - const nested = error.cause instanceof PreparationError ? error.cause.message : undefined; - return nested === undefined || nested.length === 0 - ? error.message - : `${error.message} (${nested})`; -}; - /** * Runs `attempt` against each candidate until one succeeds. When every candidate fails, the - * returned error is still the primary's. A fallback failure, including a checksum mismatch, is - * logged as a warning, and a fallback that succeeds names the host it used. + * returned error names each attempted source with its failure detail (the sole source's own + * error when there was nothing to aggregate). A fallback failure, including a checksum mismatch, + * is logged as a warning, and a fallback that succeeds names the host it used. */ const firstSuccess = ( + label: string, + artifact: Pick, candidates: readonly [T, ...ReadonlyArray], describe: (candidate: T) => string, attempt: (candidate: T) => Effect.Effect, ): Effect.Effect => { const [primary, ...fallbacks] = candidates; const fallback = ( - primaryError: PreparationError, + failures: ReadonlyArray, remaining: ReadonlyArray, ): Effect.Effect => { const [candidate, ...rest] = remaining; - if (candidate === undefined) return Effect.fail(primaryError); + if (candidate === undefined) { + const [first] = failures; + if (failures.length === 1 && first !== undefined) return Effect.fail(first[1]); + const detail = failures + .map(([failed, error]) => `${describe(failed)} (${errorChainMessage(error)})`) + .join("; "); + // The message already carries every failure, so a cause would repeat it in chain renderers. + return Effect.fail( + new PreparationError({ + message: `${label}: ${detail}`, + service: artifact.service, + version: artifact.version, + }), + ); + } return attempt(candidate).pipe( Effect.tap(() => Effect.logInfo(`Slim-services used ${describe(candidate)}`)), Effect.tapError((cause) => Effect.logWarning( - `Slim-services fallback ${describe(candidate)} failed: ${fallbackDetail(cause)}`, + `Slim-services fallback ${describe(candidate)} failed: ${errorChainMessage(cause)}`, ), ), - Effect.catch(() => fallback(primaryError, rest)), + Effect.catch((cause) => fallback([...failures, [candidate, cause] as const], rest)), ); }; - return attempt(primary).pipe(Effect.catch((primaryError) => fallback(primaryError, fallbacks))); + return attempt(primary).pipe( + Effect.catch((primaryError) => fallback([[primary, primaryError]], fallbacks)), + ); }; const checksumFor = (contents: string, archiveName: string): string | undefined => @@ -322,24 +335,29 @@ export const slimServicesChecksum = Effect.fn("SlimServicesSource.checksum")(fun artifact: SlimServicesArtifact, backoff: Schedule.Schedule = transferBackoff, ) { - return yield* firstSuccess(artifact.checksums, describeChecksumSource, (source) => - source.kind === "oci" - ? ociArchiveDigest(source, backoff) - : fetchBytes(source.url, backoff).pipe( - Effect.map((bytes) => new TextDecoder().decode(bytes)), - Effect.flatMap((contents) => { - const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); - return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) - ? Effect.fail( - new PreparationError({ - message: "Slim-services checksum is missing", - service: artifact.service, - version: artifact.version, - }), - ) - : Effect.succeed(checksum.toLowerCase()); - }), - ), + return yield* firstSuccess( + "Unable to resolve the slim-services checksum", + artifact, + artifact.checksums, + describeChecksumSource, + (source) => + source.kind === "oci" + ? ociArchiveDigest(source, backoff) + : fetchBytes(source.url, backoff).pipe( + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.flatMap((contents) => { + const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); + return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) + ? Effect.fail( + new PreparationError({ + message: "Slim-services checksum is missing", + service: artifact.service, + version: artifact.version, + }), + ) + : Effect.succeed(checksum.toLowerCase()); + }), + ), ); }); @@ -496,6 +514,8 @@ export const makeSlimServicesSource = ( return yield* Effect.gen(function* () { const artifact = yield* resolveArtifact(request); yield* firstSuccess( + "Unable to download the slim-services archive", + artifact, artifact.mirrors, (mirror) => mirror.downloadUrl, (mirror) => diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 467c31b253..9423210184 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -377,7 +377,7 @@ describe("slim-services artifact source", () => { ), ); - it.live("reports the primary checksum failure when every checksum source fails", () => + it.live("names every failed checksum source in the aggregated error", () => Effect.gen(function* () { const mirrored: SlimServicesArtifact = { ...artifact, @@ -391,7 +391,16 @@ describe("slim-services artifact source", () => { requested.push(requestUrl(input)); return Promise.resolve(new Response("", { status: 403 })); }, slimServicesChecksum(mirrored, immediate).pipe(Effect.exit)); - expect(errorOf(failed)?.message).toBe("Unable to download https://release.test/SHA256SUMS"); + expect(errorOf(failed)?.message).toBe( + "Unable to resolve the slim-services checksum: https://release.test/SHA256SUMS " + + "(Unable to download https://release.test/SHA256SUMS: HTTP 403); " + + "registry.test/supabase/cli/demo:v1.0.0-native-linux-amd64 (Unable to download " + + "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test: HTTP 403)", + ); + expect(errorOf(failed)).toMatchObject({ + service: artifact.service, + version: artifact.version, + }); expect(requested).toEqual([ "https://release.test/SHA256SUMS", "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test", @@ -445,7 +454,10 @@ describe("slim-services artifact source", () => { .materialize(request, rejected, expected) .pipe(Effect.exit), ); - expect(errorOf(failed)?.message).toBe("Unable to download slim-services archive"); + const message = errorOf(failed)?.message; + expect(message).toContain("Unable to download the slim-services archive"); + expect(message).toContain("https://release.test/demo.tar.zst"); + expect(message).toContain("https://bucket.test/bad.tar.zst"); expect(yield* fs.exists(`${rejected}/bin/demo`)).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ), diff --git a/packages/stack/src/runtime/postgres-user.ts b/packages/stack/src/runtime/postgres-user.ts index b8019c81c9..975a8e1c87 100644 --- a/packages/stack/src/runtime/postgres-user.ts +++ b/packages/stack/src/runtime/postgres-user.ts @@ -21,15 +21,23 @@ const sandboxes = [ detect: (env: Environment) => (env["CLAUDECODE"] ?? "") !== "" && env["IS_SANDBOX"] === "yes", preferredUsers: ["ubuntu"], }, + { + // Set only in Modal Sandboxes, not Functions; images have no fixed account, so rely on the scan. + name: "Modal Sandbox", + detect: (env: Environment) => (env["MODAL_SANDBOX_ID"] ?? "") !== "", + preferredUsers: [], + }, ]; -const environmentNames = [NATIVE_POSTGRES_USER_ENV, "CLAUDECODE", "IS_SANDBOX"]; +const environmentNames = [NATIVE_POSTGRES_USER_ENV, "CLAUDECODE", "IS_SANDBOX", "MODAL_SANDBOX_ID"]; export type NativePostgresUser = | { readonly _tag: "NotNeeded" } | { readonly _tag: "StepDown"; readonly user: PasswdEntry; readonly message: string } | { readonly _tag: "Unavailable"; readonly message: string; readonly suggestion: string }; -const suggestion = `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user.`; +const suggestion = + `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user, creating one if ` + + "needed (on Linux, for example `useradd --system --user-group supabase-postgres`)."; const unavailable = (message: string): NativePostgresUser => ({ _tag: "Unavailable", message, diff --git a/packages/stack/src/runtime/postgres-user.unit.test.ts b/packages/stack/src/runtime/postgres-user.unit.test.ts index bef7faf9dd..99f0b03e15 100644 --- a/packages/stack/src/runtime/postgres-user.unit.test.ts +++ b/packages/stack/src/runtime/postgres-user.unit.test.ts @@ -18,6 +18,7 @@ const nobody = entry("nobody", 65_534); const ubuntu = entry("ubuntu", 1000); const dev = entry("dev", 1001); const sandbox = { CLAUDECODE: "1", IS_SANDBOX: "yes" }; +const modal = { MODAL_SANDBOX_ID: "sb-01" }; const override = (name: string) => ({ [NATIVE_POSTGRES_USER_ENV]: name }); const asRoot = (env: Record, passwd: ReadonlyArray) => @@ -82,7 +83,7 @@ describe("resolvePostgresUser", () => { expect(asRoot({ CLAUDECODE: "1" }, [root, ubuntu])).toEqual({ _tag: "Unavailable", message: "PostgreSQL cannot be run as root", - suggestion: `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user.`, + suggestion: expect.stringContaining(`Set ${NATIVE_POSTGRES_USER_ENV}=`), }); }); @@ -92,4 +93,20 @@ describe("resolvePostgresUser", () => { "Running as root in Claude Code sandbox; PostgreSQL will run as preferred user 'ubuntu' (uid 1000)", }); }); + + it("detects a Modal Sandbox and scans for its node account, since it has no preferred user", () => { + expect(userOf(modal, [root, entry("node", 1000)])).toBe("node"); + }); + + it("suggests creating an account when a Modal Sandbox has none usable", () => { + expect(asRoot(modal, [root])).toEqual({ + _tag: "Unavailable", + message: "PostgreSQL cannot be run as root and Modal Sandbox has no non-root user", + suggestion: expect.stringContaining("useradd --system --user-group supabase-postgres"), + }); + }); + + it("does not detect a sandbox from MODAL_TASK_ID alone", () => { + expect(userOf({ MODAL_TASK_ID: "ta-01" }, [root, entry("node", 1000)])).toBe("Unavailable"); + }); }); diff --git a/packages/stack/tests/compiled-dispatch-fixture.ts b/packages/stack/tests/compiled-dispatch-fixture.ts index 2c34f98a4b..a4ac872efd 100644 --- a/packages/stack/tests/compiled-dispatch-fixture.ts +++ b/packages/stack/tests/compiled-dispatch-fixture.ts @@ -1,5 +1,5 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Data, Effect, Layer, Option, Stream } from "effect"; +import { Data, Effect, FileSystem, Layer, Option, Stream } from "effect"; import { runHostProcessIfDispatched, runNativeProcessIfDispatched, @@ -8,6 +8,7 @@ import { connectHost, launchHost, shutdownHost, + ownerExitProbe, waitForOwnerExit, type HostEndpoint, } from "../src/HostProcess.ts"; @@ -34,7 +35,10 @@ if (!(await runHostProcessIfDispatched(argv)) && !(await runNativeProcessIfDispa const refusal = yield* shutdownHost(access, false); if (Option.isSome(refusal)) return yield* new FixtureError({ message: refusal.value.message }); - yield* waitForOwnerExit(access.endpoint.pid); + yield* waitForOwnerExit( + access.endpoint.pid, + ownerExitProbe(yield* FileSystem.FileSystem), + ); } output(access.endpoint); }), From 04b1ce9f14a5d46625c42670da158ea4ce792a70 Mon Sep 17 00:00:00 2001 From: kanad Date: Tue, 29 Sep 2026 08:57:33 +0000 Subject: [PATCH 26/71] fix(cli): label non-stable builds in the root help description (#6868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The root help header is hard-coded to `Supabase CLI (stable channel).`, so beta releases, PR preview packages, and source runs all claim to be stable. - Stable releases now print `Supabase CLI.` with no label. - Other builds name what they are, derived from the prerelease identifier in `CLI_VERSION`: `-beta.N` prints `Supabase CLI (beta channel).`, `-pr.N` prints `Supabase CLI (preview build).`, and every other prerelease (including `-dev` and `-automated`) prints `Supabase CLI (development build).` - The semver parser moves from the upgrade notice into the shared version module so the header and the notice agree on what a prerelease is. `--version` output is unchanged. A survey of popular developer CLIs found none that labels stable builds by channel in the help header, and only one that labels prereleases there. Details and alternatives considered are in CLI-2523. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 --- apps/cli/src/cli/root.ts | 17 +++- apps/cli/src/cli/root.unit.test.ts | 17 ++++ .../src/command-internal/upgrade-notice.ts | 33 +------- .../shared/cli/version.integration.test.ts | 84 ++++++++++++------- apps/cli/src/shared/cli/version.ts | 48 +++++++++++ apps/cli/src/shared/cli/version.unit.test.ts | 39 +++++++++ 6 files changed, 178 insertions(+), 60 deletions(-) create mode 100644 apps/cli/src/cli/root.unit.test.ts create mode 100644 apps/cli/src/shared/cli/version.unit.test.ts diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 023a556dd1..460cd562b7 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -52,6 +52,7 @@ import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { vanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { whoamiCommand } from "../commands/whoami/whoami.command.ts"; import { OutputFormatFlag } from "../shared/cli/global-flags.ts"; +import { CLI_VERSION, cliBuildChannel } from "../shared/cli/version.ts"; import { outputLayerFor } from "../shared/output/output.layer.ts"; import { quietProgressTextOutputLayer } from "../output/quiet-progress-text-output.layer.ts"; import { makeGoProxyLayer } from "../command-internal/go-proxy.layer.ts"; @@ -89,6 +90,20 @@ const stackStatusAliasCommand = stackStatusCommand.pipe( Command.provide(stackRuntimeLayer), ); +/** Stable builds carry no label; other builds say what they are so help output never claims stability it lacks. */ +export function rootDescription(version: string): string { + switch (cliBuildChannel(version)) { + case "stable": + return "Supabase CLI."; + case "beta": + return "Supabase CLI (beta channel)."; + case "preview": + return "Supabase CLI (preview build)."; + case "development": + return "Supabase CLI (development build)."; + } +} + export const rootCommandForFeatures = ( options: { readonly stackBackend?: StackBackend; @@ -96,7 +111,7 @@ export const rootCommandForFeatures = ( } = {}, ): CliRootCommand => Command.make("supabase").pipe( - Command.withDescription("Supabase CLI (stable channel)."), + Command.withDescription(rootDescription(CLI_VERSION)), Command.withSubcommands([ backupsCommand, bootstrapCommand, diff --git a/apps/cli/src/cli/root.unit.test.ts b/apps/cli/src/cli/root.unit.test.ts new file mode 100644 index 0000000000..43796bdb46 --- /dev/null +++ b/apps/cli/src/cli/root.unit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { rootDescription } from "./root.ts"; + +describe("rootDescription", () => { + it("leaves stable releases unlabeled", () => { + expect(rootDescription("3.0.0")).toBe("Supabase CLI."); + }); + + it.each([ + ["3.0.0-beta.12", "Supabase CLI (beta channel)."], + ["0.0.0-pr.1234", "Supabase CLI (preview build)."], + ["0.0.0-dev", "Supabase CLI (development build)."], + ["0.0.0-automated", "Supabase CLI (development build)."], + ])("labels %j as %j", (version, expected) => { + expect(rootDescription(version)).toBe(expected); + }); +}); diff --git a/apps/cli/src/command-internal/upgrade-notice.ts b/apps/cli/src/command-internal/upgrade-notice.ts index b0e8825c4e..c9cdb271cc 100644 --- a/apps/cli/src/command-internal/upgrade-notice.ts +++ b/apps/cli/src/command-internal/upgrade-notice.ts @@ -18,7 +18,7 @@ import { lastGlobalFlagValue, rootFlagTokens, } from "../shared/cli/run.ts"; -import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION } from "../shared/cli/version.ts"; +import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION, parseSemver } from "../shared/cli/version.ts"; import { bold, yellow } from "./colors.ts"; import { parseDotEnv } from "./dotenv.ts"; import { candidateDotenvFilenames } from "./project-environment.ts"; @@ -61,33 +61,6 @@ function debugEnabled( const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); -interface ParsedSemver { - readonly nums: readonly [string, string, string]; - readonly prerelease: string; -} - -function parseSemver(version: string): ParsedSemver | undefined { - const match = - /^v(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?(?![\s\S])/.exec( - version, - ); - if (match === null) return undefined; - const prerelease = match[4] ?? ""; - if ( - prerelease - .split(".") - .some( - (identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier[0] === "0", - ) - ) { - return undefined; - } - return { - nums: [match[1]!, match[2] ?? "0", match[3] ?? "0"], - prerelease, - }; -} - function compareNumericIdentifier(left: string, right: string): number { if (left.length !== right.length) return left.length < right.length ? -1 : 1; if (left === right) return 0; @@ -99,9 +72,9 @@ function compareNumericIdentifier(left: string, right: string): number { * suggests an upgrade; an invalid current version always does. */ export function isNewerCliVersion(latestTag: string, currentVersion: string): boolean { - const latest = parseSemver(latestTag); + const latest = latestTag.startsWith("v") ? parseSemver(latestTag.slice(1)) : undefined; if (latest === undefined) return false; - const current = parseSemver(`v${currentVersion}`); + const current = parseSemver(currentVersion); if (current === undefined) return true; for (let index = 0; index < 3; index++) { const comparison = compareNumericIdentifier(latest.nums[index]!, current.nums[index]!); diff --git a/apps/cli/src/shared/cli/version.integration.test.ts b/apps/cli/src/shared/cli/version.integration.test.ts index b3ee0e4f01..77edf2dadb 100644 --- a/apps/cli/src/shared/cli/version.integration.test.ts +++ b/apps/cli/src/shared/cli/version.integration.test.ts @@ -7,44 +7,70 @@ import { vi } from "vitest"; import { rootCommand } from "../../cli/root.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { CliArgs } from "./cli-args.service.ts"; +import { CLI_VERSION } from "./version.ts"; const formatLogArg = (value: unknown): string => typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); -describe("CLI --version (text)", () => { - const versionLayer = (args: ReadonlyArray) => - Layer.mergeAll( - CliOutput.layer(textCliOutputFormatter()), - Layer.succeed(CliArgs, { args }), - BunServices.layer, +const builtinLayer = (args: ReadonlyArray) => + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + BunServices.layer, + ); + +/** + * Captures `console.log` while `run` executes. Spying on `console.log` alone is reliable here; + * `run.integration.test.ts` explains why pairing it with a `console.error` spy is not. + */ +async function captureLogs(run: () => Promise): Promise> { + const logs: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((first?: unknown, ...rest: unknown[]) => { + const line = + rest.length === 0 + ? first === undefined + ? "" + : formatLogArg(first) + : [first, ...rest].map(formatLogArg).join(" "); + logs.push(line); + }); + try { + await run(); + } finally { + spy.mockRestore(); + } + return logs; +} + +describe("CLI --help (text)", () => { + test("source runs describe themselves as a development build", async () => { + // `Command.runWith` keeps handler/global-flag services in the effect type even when the + // built-in `--help`/`--version` exits early; only BunServices + CliOutput are needed here. + const logs = await captureLogs(() => + Effect.runPromise( + Command.runWith(rootCommand, { version: CLI_VERSION })(["--help"]).pipe( + Effect.provide(builtinLayer(["--help"])), + ) as Effect.Effect, + ), ); + const help = logs.join("\n"); + expect(help).toContain("Supabase CLI (development build)."); + expect(help).not.toContain("stable channel"); + }); +}); +describe("CLI --version (text)", () => { test("CLI prints bare semver on stdout", async () => { - const logs: string[] = []; - const spy = vi - .spyOn(console, "log") - .mockImplementation((first?: unknown, ...rest: unknown[]) => { - const line = - rest.length === 0 - ? first === undefined - ? "" - : formatLogArg(first) - : [first, ...rest].map(formatLogArg).join(" "); - logs.push(line); - }); - try { - // `Command.runWith` keeps handler/global-flag services in the effect type even when - // `--version` exits early; only BunServices + CliOutput are needed at runtime here. - await Effect.runPromise( - Command.runWith(rootCommand, { version: "2.99.0-beta.1" })(["--version"]).pipe( - Effect.provide(versionLayer(["--version"])), + const version = "2.99.0-beta.1"; + const logs = await captureLogs(() => + Effect.runPromise( + Command.runWith(rootCommand, { version })(["--version"]).pipe( + Effect.provide(builtinLayer(["--version"])), ) as Effect.Effect, - ); - } finally { - spy.mockRestore(); - } + ), + ); expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0]).toMatch(/^\d+\.\d+\.\d+/); + expect(logs[0]).toBe(version); expect(logs[0]).not.toMatch(/supabase\s+v/i); }); diff --git a/apps/cli/src/shared/cli/version.ts b/apps/cli/src/shared/cli/version.ts index 04c39c621f..e7f5d99d40 100644 --- a/apps/cli/src/shared/cli/version.ts +++ b/apps/cli/src/shared/cli/version.ts @@ -13,3 +13,51 @@ export const CLI_VERSION = */ export const CLI_UPGRADE_GUIDE_URL = "https://supabase.com/docs/guides/cli/getting-started#updating-the-supabase-cli"; + +export interface ParsedSemver { + readonly nums: readonly [string, string, string]; + readonly prerelease: string; +} + +/** + * Parses a bare semver string (no leading `v`); minor and patch may be omitted and build + * metadata is ignored. Returns `undefined` for anything the semver grammar rejects. + */ +export function parseSemver(version: string): ParsedSemver | undefined { + const match = + /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?(?![\s\S])/.exec( + version, + ); + if (match === null) return undefined; + const prerelease = match[4] ?? ""; + if ( + prerelease + .split(".") + .some( + (identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier[0] === "0", + ) + ) { + return undefined; + } + return { + nums: [match[1]!, match[2] ?? "0", match[3] ?? "0"], + prerelease, + }; +} + +/** + * Which kind of build a CLI version string identifies. `beta` is the `develop` release train, + * `preview` is a pull-request package, and `development` covers source runs, local builds, and + * any prerelease identifier the release pipeline does not produce. + */ +export type CliBuildChannel = "stable" | "beta" | "preview" | "development"; + +export function cliBuildChannel(version: string): CliBuildChannel { + const parsed = parseSemver(version); + if (parsed === undefined) return "development"; + if (parsed.prerelease === "") return "stable"; + const identifier = parsed.prerelease.split(".")[0]; + if (identifier === "beta") return "beta"; + if (identifier === "pr") return "preview"; + return "development"; +} diff --git a/apps/cli/src/shared/cli/version.unit.test.ts b/apps/cli/src/shared/cli/version.unit.test.ts new file mode 100644 index 0000000000..a7903de40b --- /dev/null +++ b/apps/cli/src/shared/cli/version.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { cliBuildChannel, parseSemver } from "./version.ts"; + +describe("parseSemver", () => { + it.each([ + ["3.0.0", { nums: ["3", "0", "0"], prerelease: "" }], + ["3.0.0-beta.12", { nums: ["3", "0", "0"], prerelease: "beta.12" }], + ["0.0.0-pr.1234", { nums: ["0", "0", "0"], prerelease: "pr.1234" }], + ["2.114", { nums: ["2", "114", "0"], prerelease: "" }], + ["2.114.0+build.7", { nums: ["2", "114", "0"], prerelease: "" }], + ])("parses %j", (version, expected) => { + expect(parseSemver(version)).toEqual(expected); + }); + + it.each([ + ["v3.0.0", "a leading v"], + ["", "an empty string"], + ["not-a-version", "non-numeric parts"], + ["02.1.0", "a leading zero in the major"], + ["2.1.0-01", "a leading zero in a numeric prerelease id"], + ["2.1.0-alpha..1", "an empty prerelease id"], + ])("rejects %j (%s)", (version) => { + expect(parseSemver(version)).toBeUndefined(); + }); +}); + +describe("cliBuildChannel", () => { + it.each([ + ["3.0.0", "stable"], + ["3.0.0-beta.12", "beta"], + ["0.0.0-pr.1234", "preview"], + ["0.0.0-dev", "development"], + ["0.0.0-automated", "development"], + ["3.1.0-alpha.1", "development"], + ["not-a-version", "development"], + ])("%j is a %s build", (version, expected) => { + expect(cliBuildChannel(version)).toBe(expected); + }); +}); From 8bc13e06635968146c5a8dbd066d77783ddc497b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 09:16:10 +0000 Subject: [PATCH 27/71] test(stack): wait for the detached owner to exit before removing its root (#6878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `StackHost.integration.test.ts` › "serves one detached owner through Effect RPC and retires after shutdown" fails intermittently with `NotFound: FileSystem.makeTempDirectoryScoped` on the test root. The owner acknowledges `/shutdown` before it finishes winding down. It then removes `owner.json`, and its lease finalizer removes `owner.lock` and the stack directory. The test's scoped temp directory is removed while those unlinks are still running. Bun's recursive `fs.rm` reports any entry that vanishes mid-delete as `ENOENT` on the root. The lease-file unlink at owner exit made this window common. ## Change The test waits for the owner process to exit after its shutdown assertions, and the failure-path finalizer does the same. `StackHost.container-shutdown.integration.test.ts` already follows this pattern, and the product's own stop already waits for exit. --- .../stack/src/StackHost.integration.test.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 34afa2bfe3..914f877ed2 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -20,7 +20,14 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClient from "effect/unstable/http/HttpClient"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration observes exact listener closure. import * as Net from "node:net"; -import { launchHost, ownerAuthorization, ownerClient, type HostAccess } from "./HostProcess.ts"; +import { + launchHost, + ownerAuthorization, + ownerClient, + ownerExitProbe, + waitForOwnerExit, + type HostAccess, +} from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import { OrchestratorError } from "./Orchestrator.ts"; import { CommandEvent, StackError } from "./Rpc.ts"; @@ -550,7 +557,14 @@ it.live( yield* Effect.addFinalizer(() => Ref.get(stopped).pipe( Effect.flatMap((value) => - value ? Effect.void : shutdownOwner(access, true).pipe(Effect.ignore), + value + ? Effect.void + : shutdownOwner(access, true).pipe( + // A destroy request is refused once the test started a plain shutdown. + Effect.ignore, + Effect.andThen(waitForOwnerExit(endpoint.pid, ownerExitProbe(fs))), + Effect.ignore, + ), ), ), ); @@ -673,6 +687,8 @@ it.live( expect(Exit.isFailure(drainingToolExit)).toBe(true); if (Exit.isFailure(drainingToolExit)) expect(Cause.pretty(drainingToolExit.cause)).toContain("Stack host is draining"); + // The owner still releases its state files after acknowledging shutdown. + yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)); yield* Ref.set(stopped, true); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), From 6ea2248ea44060199f484d692d1c44f5fa5f6cd4 Mon Sep 17 00:00:00 2001 From: kanad Date: Tue, 29 Sep 2026 09:17:08 +0000 Subject: [PATCH 28/71] chore: bump pnpm/bun/node, various cleanups (#6806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - bumps bun (`v1.4.1` ➡️ `v1.4.2`) - bumps `@types/bun` accordingly, which allows us to clean up our shared compilation options - bumps pnpm (`v12.4.0` ➡️ `v12.8.1`) - ~~enables the new [`autoDedupe`](https://pnpm.io/settings/dependency-resolution#autodedupe) option, which allows us to clean up a bit of config (**edit**: jk, this doesn't actually work 😔)~~ - bumps node.js (`v24.20.0` ➡️ `v24.21.0`) - bumps our mise/pnpm lockfiles accordingly - small cleanups in our `turbo.json` cache inputs (`mise.lock` makes it unnecessary to include `.bun-version` and `mise.toml` in our task inputs) - points to `.bun-version` in our stack tests --------- Co-authored-by: Julien Goux --- .bun-version | 2 +- apps/cli/scripts/compile-options.ts | 6 +- mise.lock | 164 ++++++++++-------- package.json | 2 +- .../src/runtime/Container.integration.test.ts | 10 +- pnpm-lock.yaml | 154 ++++++++-------- pnpm-workspace.yaml | 4 +- turbo.json | 7 +- 8 files changed, 182 insertions(+), 167 deletions(-) diff --git a/.bun-version b/.bun-version index 347f5833ee..9df886c42a 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.4.1 +1.4.2 diff --git a/apps/cli/scripts/compile-options.ts b/apps/cli/scripts/compile-options.ts index 0960516c5f..b18cf9a2e2 100644 --- a/apps/cli/scripts/compile-options.ts +++ b/apps/cli/scripts/compile-options.ts @@ -4,17 +4,17 @@ import { Effect } from "effect"; /** * Shared compile options keep release, local, and E2E builds exercising the shipped binary - * compilation behavior. `bytecodeDepth` is accepted by Bun 1.4.1 but is not yet in bun-types. + * compilation behavior. */ export const compileOptions = { minify: true, bytecode: true, bytecodeDepth: 2, - format: "esm" as const, + format: "esm", // `oxfmt` is an optional peer of `@supabase/postgrest-typegen`, loaded only by the default // TypeScript formatter; `gen types` supplies its own, so the binary ships without it. external: ["oxfmt"], -}; +} as const satisfies Partial; /** * Embeds the stack release of the sources being compiled, so the binary drives exactly the owners diff --git a/mise.lock b/mise.lock index beb5bc0ac6..5980635e1a 100644 --- a/mise.lock +++ b/mise.lock @@ -3,53 +3,53 @@ lockfile_version = 1 [[tools.bun]] -version = "1.4.1" +version = "1.4.2" backend = "core:bun" -specifiers = ["1.4.1"] +specifiers = ["1.4.2"] [tools.bun."platforms.linux-arm64"] -checksum = "sha256:580ce77533108dc6b10bec1721397e4f5aa44e909726da2451d483dfc5e581d6" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-aarch64.zip" +checksum = "sha256:54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-aarch64.zip" [tools.bun."platforms.linux-arm64-musl"] -checksum = "sha256:53895807a00508f70e76715947097aa533ec520aac066501c00fb77d2b1a7a6c" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-aarch64-musl.zip" +checksum = "sha256:71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-aarch64-musl.zip" [tools.bun."platforms.linux-x64"] -checksum = "sha256:74c1c3bee7cd998500c8f969cd8972355ac6a07207e94a39eece1999b56ffabf" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64.zip" +checksum = "sha256:36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64.zip" [tools.bun."platforms.linux-x64-baseline"] -checksum = "sha256:a8c9c6738202e2fced555dd860a953c56c0cd059f75041e7010ae81a32802646" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64-baseline.zip" +checksum = "sha256:c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-baseline.zip" [tools.bun."platforms.linux-x64-musl"] -checksum = "sha256:ea116fe09f2f764c87b9bf735225b781d1f7674ca58d66040470f38a7943c8ab" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64-musl.zip" +checksum = "sha256:4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-musl.zip" [tools.bun."platforms.linux-x64-musl-baseline"] -checksum = "sha256:74d04038a21e6ae816f9e74525b754b85294be99632b336cb4c57019c9313829" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64-musl-baseline.zip" +checksum = "sha256:76e1db84e98f22f78de0a87e309bfbbf297732847f9720db36750646c85c8c18" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-musl-baseline.zip" [tools.bun."platforms.macos-arm64"] -checksum = "sha256:d8973ce835fa7867e5cc79afee6fc6f1ae0117aa4bd5fc2546fd00c512f71386" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-darwin-aarch64.zip" +checksum = "sha256:90987a3a16d7db556d886ac3d551e7b6d3edf0a1cf43acaed622e8676be1d12f" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-darwin-aarch64.zip" [tools.bun."platforms.macos-x64"] -checksum = "sha256:8f34239f276a3f0d27bfcd1ffecfe5d2127e74fb0aa4c0971a0cdec7b225c965" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-darwin-x64.zip" +checksum = "sha256:80520d7e17526308c9185d261679ac6d27798d3803a0e9f7ff9121ab8affb012" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-darwin-x64.zip" [tools.bun."platforms.macos-x64-baseline"] -checksum = "sha256:498e76d61bbe87d2306f65fed60ae86b6b0c8ee2da709f83202808db1a09e407" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-darwin-x64-baseline.zip" +checksum = "sha256:bad5bbd6cf14d0980d115f5954c9ff904df619d5e994d2da1ffccd3f316300b0" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-darwin-x64-baseline.zip" [tools.bun."platforms.windows-x64"] -checksum = "sha256:52b1f3028b01f43d37fefdf669d034a1ee2e0d96c56bb13c393bdaf169b1af84" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-windows-x64.zip" +checksum = "sha256:ce4c17497b2f29712a99d3d53f028de28cd42e3bacb8589599e7f000e49b6405" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-windows-x64.zip" [tools.bun."platforms.windows-x64-baseline"] -checksum = "sha256:2d1871e72b28165a8574a9c91de867cbe499f55b8c75facf4fb9e42888eddba6" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-windows-x64-baseline.zip" +checksum = "sha256:78c221c2376f79731ccf4e4af0b3bb46d81fefa3296c5abee09ad8a1b21e68c6" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-windows-x64-baseline.zip" [[tools.go]] version = "1.26.5" @@ -172,109 +172,121 @@ url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/4 provenance = "github-attestations" [[tools.node]] -version = "24.20.0" +version = "24.21.0" backend = "core:node" specifiers = ["24"] [tools.node."platforms.linux-arm64"] -checksum = "sha256:3515603e2487879a39bc75716f1a2affd027500c64ba50e845cf72cb33219013" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-arm64.tar.gz" +checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz" [tools.node."platforms.linux-arm64-musl"] -checksum = "sha256:2c8c507ccb0f20812d9526ba8ca454b1652aadef68fc8bad06f07fb1122dd1ef" -url = "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-arm64-musl.tar.gz" +checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4" +url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz" [tools.node."platforms.linux-x64"] -checksum = "sha256:855d581f8a4eb1a8117e3426de25fe02770592febcfb31369aee1ffbfee9e8ec" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.gz" +checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" [tools.node."platforms.linux-x64-baseline"] -checksum = "sha256:855d581f8a4eb1a8117e3426de25fe02770592febcfb31369aee1ffbfee9e8ec" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.gz" +checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" [tools.node."platforms.linux-x64-musl"] -checksum = "sha256:9ae1399fef4bd8990e15773ce1327b336a20b9e97d8c7549f4f42ca73c43f562" -url = "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.gz" +checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a" +url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz" [tools.node."platforms.linux-x64-musl-baseline"] -checksum = "sha256:9ae1399fef4bd8990e15773ce1327b336a20b9e97d8c7549f4f42ca73c43f562" -url = "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.gz" +checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a" +url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz" [tools.node."platforms.macos-arm64"] -checksum = "sha256:40e5607e5ecb3db9192723776da2d75d966260fc74a7a9e731c1bd67dda96bc8" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-darwin-arm64.tar.gz" +checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz" [tools.node."platforms.macos-x64"] -checksum = "sha256:9e5b2644cf107befb6aefca676b96d3296bc10138096f022ed378d6233ed81f4" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-darwin-x64.tar.gz" +checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" [tools.node."platforms.macos-x64-baseline"] -checksum = "sha256:9e5b2644cf107befb6aefca676b96d3296bc10138096f022ed378d6233ed81f4" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-darwin-x64.tar.gz" +checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" [tools.node."platforms.windows-x64"] -checksum = "sha256:6cac9ffbca8f6a47091e4b5c772e0606049c3871cb67d900c0cedde630e545ba" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-win-x64.zip" +checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip" [tools.node."platforms.windows-x64-baseline"] -checksum = "sha256:6cac9ffbca8f6a47091e4b5c772e0606049c3871cb67d900c0cedde630e545ba" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-win-x64.zip" +checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip" [[tools.pnpm]] -version = "12.4.0" +version = "12.8.1" backend = "aqua:pnpm/pnpm" -specifiers = ["12.4.0"] +specifiers = ["12.8.1"] [tools.pnpm."platforms.linux-arm64"] -checksum = "sha256:74fc88eacd2975df4ec5bae517482986ce824bc35a7ac5965c1baf1c854fe933" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351855" +checksum = "sha256:75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935899" provenance = "github-attestations" [tools.pnpm."platforms.linux-arm64-musl"] -checksum = "sha256:e2e9ec97dbf6165e0936883c078850af967fd7842cfabbb130c24f594bba589f" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-arm64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351859" +checksum = "sha256:27ad77bdf5368c1747f46fff0d0ee213ed6185aaf69e13342964f5f7cc3f8714" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-arm64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935889" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64"] -checksum = "sha256:fcff7ecad46365d356718a40a88a72b4c02eae9d075d567e032697d769f3d999" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351861" +checksum = "sha256:db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935887" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-baseline"] -checksum = "sha256:fcff7ecad46365d356718a40a88a72b4c02eae9d075d567e032697d769f3d999" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351861" +checksum = "sha256:db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935887" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl"] -checksum = "sha256:cb90483341f8601d7d62c93dbd3f17f057e1d95f8cc8720cfe6df4411a616bde" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351865" +checksum = "sha256:f0a2db13d0a1b63c0053a5ddcfbf5c454b040e7ef54ca8771750e2cb55b86693" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935901" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl-baseline"] -checksum = "sha256:cb90483341f8601d7d62c93dbd3f17f057e1d95f8cc8720cfe6df4411a616bde" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351865" +checksum = "sha256:f0a2db13d0a1b63c0053a5ddcfbf5c454b040e7ef54ca8771750e2cb55b86693" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935901" provenance = "github-attestations" [tools.pnpm."platforms.macos-arm64"] -checksum = "sha256:dae611f18e7acc416fac9134ac86fcb1cc8670868ea2e5a51e82e184aec01d63" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-darwin-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351864" +checksum = "sha256:8eeeae4cd714b2f1755750d303f5b1bcfe23d95ed7245536305d0c3877c5ce41" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935883" +provenance = "github-attestations" + +[tools.pnpm."platforms.macos-x64"] +checksum = "sha256:8cbc5a840b4bcd8c0da878e6616a832d88e98a8acbedf1736310b395467f4a13" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-darwin-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935890" +provenance = "github-attestations" + +[tools.pnpm."platforms.macos-x64-baseline"] +checksum = "sha256:8cbc5a840b4bcd8c0da878e6616a832d88e98a8acbedf1736310b395467f4a13" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-darwin-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935890" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64"] -checksum = "sha256:0c25c9921d61171c1551ed38cf6bc5240bcd3acaff81431a4edb5fe33c720674" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351858" +checksum = "sha256:823fc131326afeee292e113e2299ca9851c3ee5148a7de921c897f0170de6f05" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935888" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64-baseline"] -checksum = "sha256:0c25c9921d61171c1551ed38cf6bc5240bcd3acaff81431a4edb5fe33c720674" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351858" +checksum = "sha256:823fc131326afeee292e113e2299ca9851c3ee5148a7de921c897f0170de6f05" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935888" provenance = "github-attestations" diff --git a/package.json b/package.json index 0640a4b9fd..8f86e271b2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "devEngines": { "packageManager": { "name": "pnpm", - "version": "12.4.0", + "version": "12.8.1", "onFail": "warn" } } diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 922332501e..e49294b952 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -8,8 +8,10 @@ import { Effect, Exit, Fiber, + FileSystem, Layer, Option, + Path, Ref, Schema, Sink, @@ -21,7 +23,13 @@ import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/u import { HttpClient } from "effect/unstable/http"; import { ContainerLaunchError, makeContainerRuntime, type ContainerProcess } from "./Container.ts"; -const image = "oven/bun:1.4.1-slim"; +const image = await Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = yield* path.fromFileUrl(new URL("../../../../.bun-version", import.meta.url)); + const version = yield* fs.readFileString(file); + return `oven/bun:${version.trim()}-slim`; +}).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); class ContainerTestError extends Data.TaggedError("ContainerTestError")<{ readonly message: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3d2f8b7a8..6a984fded6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.4.0 - version: 12.4.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.4.0': - resolution: {integrity: sha512-sAwslzCw74OpqK2P9l39cgdrRWHSqf5wEjB58JEzeVX6wdLvaHyg9i/GeRK5Ou6PZNA3AkD4yLO3c/y7UqOf8w==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.4.0': - resolution: {integrity: sha512-Ps3Gz0OrqYjuRfhzeNqcvIow6QmNrU3BSzMxJu5rUCSl7inVUUFX2gZbNL9naQsNLoorKCdxUf4N+WI9Mr1WBg==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.4.0': - resolution: {integrity: sha512-75EYiF8GuiTsnvP4cbZsviMBjohXUYtg2zjD4gdfhqxlU1y9Nj+KdEsbH4jfgB/FgyJSYPB2rqfmvvgLnRlVug==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.4.0': - resolution: {integrity: sha512-b74TzBg8lxl0lqZImGOXpRbAGcqVKX+UMckl3wG+KH52yYHI86VBJP86HbJX30HJ/EFeC6Tgbz2E4b5hhKRvdA==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.4.0': - resolution: {integrity: sha512-A45d6axdQIFNyNTYZAC64wh5+6LJ6dNKhNAoNMNi/EC5y9CRfv0GL3ZYDBqpkmsN9KMAkVsFWizq/Ng6xtjnhw==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.4.0': - resolution: {integrity: sha512-yS6DNel7twfEUoW1yka1hpQrnhRe/s1JZ1MThVfgrmNQrNaPyHxQvAihm/GtL2CM6OeMV6z5532H/W/yDjQp5g==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.4.0': - resolution: {integrity: sha512-6npQUwq3D/WXbTgR4evApEKQ9ITznZ6Iz/dptcjBzA7+wSLTaYkK98Od2wsHCoPmEFb9tGtM+cvG82+wy0o9uA==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.4.0': - resolution: {integrity: sha512-7shJ4WytyvBEdjrbIDqx2gDAH7sr0HBDooTmnNQ7DlzcLeeGi7Yqir7gJjOTm6s9S1cVnT56IwmqnnwQMP1HTQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.4.0': - resolution: {integrity: sha512-q03EGUFoe/oOU/67E3sEAePr3qjFu8xrsX+WOXTodcFXfO5FondC6TPs7BSwhTiV27j3jeDP56qhJP05pXn+vw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.4.0': - resolution: {integrity: sha512-GZ5YellCtNnaLe9zVj9l3avlw9CbSzExUFDh7v+tVbLfOOfkkRLpx2lsw1ytJ/nFWvxF2TO6M6Q9JDT7q8svRg==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.4.0': - resolution: {integrity: sha512-c8YyjVL39L48tRg9i3iw3d90fN6q84UjxlgWBhplcBOpzCgmglDVkPMtEAVDC+t9iobvYzs2hJnmTqLaA87MVA==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.4.0': - resolution: {integrity: sha512-SQVgRkcR4Xyqf8+VNbtY0rtcEnfDq48RhH30HWo2/UfqKEfle2rOMyGZOmN1DbMw4ZzG5mWYoC81O7ZqHFZcPw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.4.0': - resolution: {integrity: sha512-ZamXPhx0X6APZJAIkcH+K9KAsKcTYwxEgOyTQ/NXE+2UHBT9bRnSQ91sBeY6ELNd58V5cmMAkXSW5xmU+2ry+w==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.4.0': - resolution: {integrity: sha512-b8bLaprnpYi/2zN0M3H9RDAHuxCP3fmV5agPPwdp9fIdjNSUkV7V/RC3L4oWwbZvVgYEjjFLx1RuJOdltoKP6g==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.4.0: - resolution: {integrity: sha512-N1NsJu1Aq0E0tlEeCfayfz67RWh0aPJAbKOAUnmk5coVjBkxNQrZd01qshCNcbPbrrOZQxWSlDdeTQU+jgVoXA==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.4.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.4.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.4.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.4.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.4.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.4.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.4.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.4.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.4.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.4.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.4.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.4.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.4.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.4.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.4.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.4.0 - '@pnpm/exe.android-x64': 12.4.0 - '@pnpm/exe.darwin-arm64': 12.4.0 - '@pnpm/exe.darwin-x64': 12.4.0 - '@pnpm/exe.freebsd-x64': 12.4.0 - '@pnpm/exe.linux-arm64': 12.4.0 - '@pnpm/exe.linux-arm64-musl': 12.4.0 - '@pnpm/exe.linux-ppc64': 12.4.0 - '@pnpm/exe.linux-riscv64': 12.4.0 - '@pnpm/exe.linux-s390x': 12.4.0 - '@pnpm/exe.linux-x64': 12.4.0 - '@pnpm/exe.linux-x64-musl': 12.4.0 - '@pnpm/exe.win32-arm64': 12.4.0 - '@pnpm/exe.win32-x64': 12.4.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' @@ -186,8 +186,8 @@ catalogs: specifier: ^1.0.11 version: 1.0.11 '@types/bun': - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^1.4.2 + version: 1.4.2 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -256,7 +256,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -350,7 +350,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@types/pg': specifier: ^8.23.1 version: 8.23.1 @@ -442,7 +442,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 typescript: specifier: 'catalog:' version: 7.0.2 @@ -506,7 +506,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) @@ -536,7 +536,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) @@ -580,7 +580,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) @@ -623,7 +623,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@types/ws': specifier: 'catalog:' version: 8.18.1 @@ -2983,8 +2983,8 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/bun@1.4.0': - resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==} + '@types/bun@1.4.2': + resolution: {integrity: sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3591,8 +3591,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.4.0: - resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} + bun-types@1.4.2: + resolution: {integrity: sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==} byte-counter@0.1.0: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} @@ -8753,9 +8753,9 @@ snapshots: tslib: 2.8.1 optional: true - '@types/bun@1.4.0': + '@types/bun@1.4.2': dependencies: - bun-types: 1.4.0 + bun-types: 1.4.2 '@types/chai@5.2.3': dependencies: @@ -9339,7 +9339,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.4.0: + bun-types@1.4.2: dependencies: '@types/node': 26.4.1 undici-types: 8.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3d232bd6b0..b9b74bd071 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ catalog: "@effect/vitest": "4.0.0-rc.112" "@supabase/supabase-js": ^2.110.7 "@tsconfig/bun": "^1.0.11" - "@types/bun": "^1.4.0" + "@types/bun": "^1.4.2" "@types/ws": "8.18.1" "typescript": "^7.0.2" "@vitest/coverage-v8": "^5.0.0" @@ -112,8 +112,6 @@ minimumReleaseAgeExclude: - "@supabase/pg-topo@1.0.0-alpha.6" - "@supabase/postgrest-typegen@0.3.1" - "@supabase/typegen@0.2.1" - - "@types/bun@1.4.0" - - "bun-types@1.4.0" - "effect@4.0.0-rc.112" - "turbo@2.10.11" diff --git a/turbo.json b/turbo.json index 835b1d15aa..70cb4b17c8 100644 --- a/turbo.json +++ b/turbo.json @@ -49,7 +49,7 @@ }, "@supabase/cli-go#build": { "cache": true, - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/mise.toml", "$TURBO_ROOT$/mise.lock"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/mise.lock"], "outputs": ["supabase-go"], "env": ["GO*", "CGO_*", "CC", "CXX"] }, @@ -58,7 +58,6 @@ "dependsOn": ["@supabase/config#build", "@supabase/cli-go#build"], "inputs": [ "$TURBO_DEFAULT$", - "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock", "$TURBO_ROOT$/packages/api/**", "$TURBO_ROOT$/packages/stack/package.json", @@ -68,7 +67,7 @@ }, "@supabase/config#build": { "cache": true, - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/mise.lock"], "outputs": ["dist/**"] }, "@supabase/api#generate": { @@ -88,7 +87,6 @@ "!content/docs/commands/**", "!public/cli/config.schema.json", "!public/cli/project-config.schema.json", - "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock" ], "outputs": [ @@ -105,7 +103,6 @@ "!content/docs/commands/**", "!public/cli/config.schema.json", "!public/cli/project-config.schema.json", - "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock" ], "outputs": [".next/**"] From ed05cb84d7d340a1e38f3cda195f33d92a1c26d7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 09:47:17 +0000 Subject: [PATCH 29/71] fix(stack): retry image pulls after transient registry failures (#6879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Image preparation retried a failed pull only when the registry rate-limited it. A dropped or briefly unavailable registry connection failed the service start on the first attempt. That covers an `EOF` from a manifest request, a connection reset, a TLS or network timeout, and a registry 5xx. It surfaced as `Error response from daemon: Get "https://ghcr.io/v2/…/manifests/…": EOF` in the Realtime service integration test. ## Change Pulls retry the whole mirror chain with the existing backoff when the engine reports a transient registry transport failure, as they already did for rate limits. Permanent rejections such as an unknown manifest or denied access still fail immediately, and so does an unreachable engine. --- packages/stack/src/runtime/Container.ts | 29 ++++++++--- ...container-image-mirror.integration.test.ts | 51 ++++++++++++++++++- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 9b7742fe24..c300a013bd 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -95,6 +95,16 @@ const errorFor = (operation: string, cause: unknown) => const rateLimited = (error: ContainerError) => /toomanyrequests|too many requests|rate limit|rate exceeded/iu.test(error.message); +/** + * Matches a dropped registry connection, not a permanent rejection or a failing engine socket + * (`error during connect`, `%2F…` hosts). `EOF` only counts after a registry request URL. + */ +const transientPullFailure = (error: ContainerError) => + !/error during connect/iu.test(error.message) && + /(?:Get|Head|Post|Put) "https?:\/\/(?!%2F)[^"]+": (?:unexpected )?EOF|connection reset by peer|i\/o timeout|TLS handshake timeout|net\/http: request canceled|502 Bad Gateway|503 Service Unavailable|504 Gateway Timeout|received unexpected HTTP status: 5\d\d/iu.test( + error.message, + ); + /** * Matches an engine CLI that is missing or reports a daemon that is not listening, not one that * rejects the caller. Podman's connection wrappers and Windows' `error during connect` also wrap @@ -108,6 +118,10 @@ const engineUnreachable = (error: ContainerError) => error.message, ); +/** A pull worth retrying: rate-limited or a dropped connection, never an unreachable engine. */ +const retryablePull = (error: ContainerError) => + (rateLimited(error) || transientPullFailure(error)) && !engineUnreachable(error); + const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; const PULL_MAX_RETRIES = 4; @@ -137,8 +151,9 @@ const mountField = (key: string, value: string) => { /** * Captures the selected local engine; each launch owns one exact container. An image whose pull * fails is pulled from the first of its `imageMirrors` that succeeds, and launches of it then use - * that mirror reference. When every mirror fails, the primary pull error is reported; a - * rate-limited primary retries the whole chain with backoff. + * that mirror reference. When every mirror fails, the primary pull error is reported; a primary + * that is rate-limited or hits a transient registry transport failure retries the whole chain + * with backoff. */ export const makeContainerRuntime = (options: { readonly engine: "docker" | "podman"; @@ -252,11 +267,13 @@ export const makeContainerRuntime = (options: { }); return yield* attempt.pipe( Effect.tapError((error) => - rateLimited(error) - ? Effect.logWarning(`Registry rate-limited the pull of ${image}`) - : Effect.void, + !retryablePull(error) + ? Effect.void + : rateLimited(error) + ? Effect.logWarning(`Registry rate-limited the pull of ${image}`) + : Effect.logWarning(`Registry pull of ${image} failed transiently`), ), - Effect.retry({ schedule: pullBackoff, times: PULL_MAX_RETRIES, while: rateLimited }), + Effect.retry({ schedule: pullBackoff, times: PULL_MAX_RETRIES, while: retryablePull }), ); }); const prepare = Effect.fn("Container.prepare")((image: string) => diff --git a/packages/stack/src/runtime/container-image-mirror.integration.test.ts b/packages/stack/src/runtime/container-image-mirror.integration.test.ts index 7cb91acfd9..e528d9d33e 100644 --- a/packages/stack/src/runtime/container-image-mirror.integration.test.ts +++ b/packages/stack/src/runtime/container-image-mirror.integration.test.ts @@ -11,14 +11,19 @@ const secondMirror = "registry.test/supabase/cli/postgrest:v16.2"; /** * Docker stand-in: `image inspect` reports `local`, `pull` is rate-limited for the counted - * `throttled` attempts and then succeeds for `pullable`, `create` refuses. + * `throttled` attempts, answers each queued `failures` message once, and then succeeds for + * `pullable`; `create` refuses. */ const fakeEngine = (options: { readonly pullable: ReadonlyArray; readonly throttled?: Readonly>; + readonly failures?: Readonly>>; }) => { const pullable = new Set(options.pullable); const throttled = new Map(Object.entries(options.throttled ?? {})); + const failures = new Map( + Object.entries(options.failures ?? {}).map(([ref, messages]) => [ref, [...messages]]), + ); const local = new Set(); const commands: string[][] = []; const handle = (exitCode: number, stdout = "", stderr = "") => @@ -42,6 +47,9 @@ const fakeEngine = (options: { const ref = args.at(-1) ?? ""; if (args[0] === "image") return Effect.succeed(handle(0, local.has(ref) ? "sha256:1" : "")); if (args[0] === "pull") { + const queued = failures.get(ref); + const message = queued?.shift(); + if (message !== undefined) return Effect.succeed(handle(1, "", message)); const remaining = throttled.get(ref) ?? 0; if (remaining > 0) { throttled.set(ref, remaining - 1); @@ -182,6 +190,47 @@ describe("container image mirror", () => { }).pipe(Effect.provide(Layer.merge(NodeServices.layer, engine.layer))); }); + it.effect("retries a pull after a transient registry transport failure", () => { + const engine = fakeEngine({ + pullable: [primary], + failures: { + [primary]: [`Get "https://ghcr.io/v2/supabase/cli/pgmeta/manifests/sha256:1": EOF`], + }, + }); + return Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + const prepared = yield* runtime.prepare(primary).pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + yield* Fiber.join(prepared); + expect(engine.commands.filter((args) => args[0] === "pull")).toHaveLength(2); + expect(engine.local.has(primary)).toBe(true); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, engine.layer))); + }); + + const permanentPullFailures = [ + "manifest unknown", + `manifest for ${primary}/eof:latest not found: manifest unknown`, + `error during connect: Get "http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.47/images/create?fromImage=eof": EOF`, + ]; + + for (const message of permanentPullFailures) { + it.effect(`does not retry a permanent pull failure: ${message}`, () => { + const engine = fakeEngine({ + pullable: [], + failures: { [primary]: [message] }, + }); + return Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + const failed = yield* runtime.prepare(primary).pipe(Effect.exit); + const error = Exit.isFailure(failed) + ? Option.getOrUndefined(Cause.findErrorOption(failed.cause)) + : undefined; + expect(error?.message).toContain(message); + expect(engine.commands.filter((args) => args[0] === "pull")).toHaveLength(1); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, engine.layer))); + }); + } + it.effect("reports the rate limit after five throttled attempts", () => { const engine = fakeEngine({ pullable: [primary], throttled: { [primary]: 10 } }); return Effect.gen(function* () { From 2124262d22387506725434968d6543e7e5ec5dd9 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 29 Sep 2026 09:47:33 +0000 Subject: [PATCH 30/71] test(cli): connect live db assertions over TLS (#6843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** The live e2e helper `queryLiveDb` connected to the project database over plaintext, so a live test querying the shared project right after `ssl-enforcement/update` could be rejected with `ESSLREQUIRED`. The helper now connects over TLS and verifies the server certificate against the Supabase CA, so it works whether SSL enforcement is on or off. ### Why Live E2E on `develop` failed in `migration/up/up.live.test.ts` before the CLI was even invoked: ``` MigrationLiveError: (ESSLREQUIRED) SSL connection is required for user: postgres ``` The live suite runs serially against one shared project, and `ssl-enforcement/update` runs immediately before `migration/up`. It turns enforcement on and then restores it, finishing once `ssl-enforcement get` reports `appliedSuccessfully`. The database can keep enforcing SSL briefly after that, so the next plaintext connection is rejected. CLI commands aren't affected, because the CLI connects over TLS by default. ### Before ```mermaid flowchart LR A["ssl-enforcement/update
enables, then restores"] --> B["migration/up
queryLiveDb over plaintext"] B -->|"enforcement still active"| C["ESSLREQUIRED"] ``` ### After ```mermaid flowchart LR A["ssl-enforcement/update
enables, then restores"] --> B["migration/up
queryLiveDb over TLS"] B --> C["Query succeeds
either posture"] ``` ### What changed - `queryLiveDb` in `apps/cli/tests/helpers/live.ts` connects with `ssl: { ca: rootCaBundle() }`: TLS with the Supabase CA bundle pinned (the same one `gen types` pins for Supabase hosts) and the hostname checked. An `sslmode` in the connection string still takes precedence. - This covers every caller of the helper: the `migration up` and `migration repair` live tests, plus `db lint` and `db advisors`. ## Linked issue None; this fixes a flaky live e2e test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/tests/helpers/live.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 397fa18da7..daada201b1 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -6,6 +6,7 @@ import { Effect, Predicate } from "effect"; import pg from "pg"; import { expect, inject, test as vitestTest } from "vitest"; +import { rootCaBundle } from "../../src/commands/gen/types/types.shared.ts"; import { type CliRunError, makeTempHome, @@ -276,7 +277,8 @@ export async function queryLiveDb>( query: string, values?: ReadonlyArray, ): Promise { - const client = new pg.Client({ connectionString: dbUrl }); + // Verified TLS against the Supabase CA, so the query works whatever the project's SSL enforcement. + const client = new pg.Client({ connectionString: dbUrl, ssl: { ca: rootCaBundle() } }); await client.connect(); try { const result = await client.query(query, values === undefined ? undefined : [...values]); From 5abfa7d2fb33b21daaeb2432cc60378257dbd417 Mon Sep 17 00:00:00 2001 From: kanad Date: Tue, 29 Sep 2026 09:52:44 +0000 Subject: [PATCH 31/71] ci(cli): cache pnpm lockfile verification and drop the Linux stack-port job (#6871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow-only. No test or runtime changes. ## Why Every job in the Test workflow runs `pnpm install --frozen-lockfile` and logs `Lockfile passes supply-chain policies (1323 entries in 11–40s)`. That is pnpm re-applying `minimumReleaseAge` and `trustPolicy` to every lockfile entry by fetching registry metadata. With a warm store the install reuses everything and downloads nothing, so this pass is effectively the whole install cost on every job. pnpm caches the verdict in `lockfile-verified.jsonl` under its cache directory, keyed by lockfile hash and the policy it was verified under. The setup action only cached the content store, so the verdict was discarded after every job. Separately, the Linux entry of the stack-port matrix duplicates work the integration job already does: that job runs the whole `@supabase/stack` integration project, which includes every file the port job lists. ## What - `.github/actions/setup/action.yml`: add a cache step for `lockfile-verified.jsonl` under `pnpm cache path`, keyed on the lockfile and `pnpm-workspace.yaml` (the verdict is bound to the policy). pnpm does not persist registry metadata during a frozen-lockfile verification, so only the verdict file is cached. - `.github/workflows/test.yml`: remove the Linux runner from the stack-port matrix. macOS and Windows stay; they are the OS coverage the job exists for. ## Expected behaviour - PRs with an unchanged lockfile: every job logs `previously verified` and the install step drops to linking. - PRs that change the lockfile: the first run verifies on every job; the next run hits. - Firewall behaviour is unchanged: registry, token handling, fork fallback, and the failure mode where a policy violation fails the install. Linear: https://linear.app/supabase/issue/CLI-2497 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Julien Goux --- .github/actions/setup/action.yml | 17 +++++++++++++++++ .github/workflows/test.yml | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 1fdd077f3b..0f61b31ba0 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -52,6 +52,23 @@ runs: restore-keys: | pnpm-store-files-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve pnpm cache path + if: inputs.dependency-cache == 'true' + id: pnpm-cache + shell: bash + run: echo "path=$(pnpm cache path --silent)" >> "$GITHUB_OUTPUT" + + # pnpm stores the lockfile's supply-chain verdict in lockfile-verified.jsonl; + # without it every job re-verifies all entries against registry metadata, + # which dominates a warm-store install. The verdict is bound to the policy + # and to the registry it was checked against. + - name: Configure pnpm verification cache + if: inputs.dependency-cache == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.pnpm-cache.outputs.path }}/lockfile-verified.jsonl + key: pnpm-verify-${{ runner.os }}-${{ inputs.dependency-firewall-token != '' && 'firewall' || 'public' }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + - name: Resolve Go cache paths if: inputs.dependency-cache == 'true' id: go-cache diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc1b76d14b..bac40c3a32 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,7 +132,9 @@ jobs: strategy: fail-fast: false matrix: - os: [blacksmith-8vcpu-ubuntu-2404, macos-latest, windows-latest] + # Linux coverage for these files comes from the integration job, which + # runs the whole stack integration project. + os: [macos-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From c7c9f123ee145d39c2adb5b4c1f19f2f480ecd7a Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:13:36 +0000 Subject: [PATCH 32/71] refactor(cli): cover `shared/telemetry` identity and consent with effect lint (CLI-2509) (#6850) ## TL;DR brings the telemetry identity and consent state under the effect lint ## whats introduced? effect lint applied to the telemetry identity and consent files: - allow list entries for `identity`, `consent`, `types`, `ai-tool` and their tests - device and session ids come from the `Crypto` service `runtime.layer` already uses, and timestamps from `Clock` - the telemetry file still decodes any json number, so an overflowed `1e999` reads back as `Infinity` like before, pinned for the current and the legacy shape - the legacy fallback fails through `NoSuchElementError` instead of a plain `Error`, still read back as no config - `ai-tool` detection goes through `Effect.tryPromise`, so its fallback to no agent is reachable - tests write fixtures through `FileSystem` and run on `it.effect` with the test clock ## ref: - related: CLI-2509 --- .oxlintrc.effect.json | 9 + .../cli/src/shared/telemetry/ai-tool.layer.ts | 12 +- .../telemetry/ai-tool.layer.unit.test.ts | 13 +- .../telemetry/consent.integration.test.ts | 131 ++++----- apps/cli/src/shared/telemetry/consent.ts | 19 +- .../src/shared/telemetry/consent.unit.test.ts | 215 +++++++------- apps/cli/src/shared/telemetry/identity.ts | 16 +- .../shared/telemetry/identity.unit.test.ts | 263 ++++++++--------- .../telemetry/runtime.layer.unit.test.ts | 267 +++++++++--------- apps/cli/src/shared/telemetry/types.ts | 10 +- 10 files changed, 488 insertions(+), 467 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 845e2382a4..7878484fd6 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -14,6 +14,15 @@ "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", "!apps/cli/src/shared/runtime/file-watcher.service.ts", + "!apps/cli/src/shared/telemetry/ai-tool.layer.ts", + "!apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts", + "!apps/cli/src/shared/telemetry/consent.integration.test.ts", + "!apps/cli/src/shared/telemetry/consent.ts", + "!apps/cli/src/shared/telemetry/consent.unit.test.ts", + "!apps/cli/src/shared/telemetry/identity.ts", + "!apps/cli/src/shared/telemetry/identity.unit.test.ts", + "!apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts", + "!apps/cli/src/shared/telemetry/types.ts", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", diff --git a/apps/cli/src/shared/telemetry/ai-tool.layer.ts b/apps/cli/src/shared/telemetry/ai-tool.layer.ts index ba45285bb0..989968df42 100644 --- a/apps/cli/src/shared/telemetry/ai-tool.layer.ts +++ b/apps/cli/src/shared/telemetry/ai-tool.layer.ts @@ -8,18 +8,16 @@ function normalizeAgentName(name: string): string { export const aiToolLayer = Layer.effect( AiTool, - Effect.promise(() => determineAgent()).pipe( + Effect.tryPromise(() => determineAgent()).pipe( Effect.map((result) => AiTool.of({ name: result.isAgent ? Option.some(normalizeAgentName(result.agent.name)) : Option.none(), }), ), - Effect.catch(() => - Effect.succeed( - AiTool.of({ - name: Option.none(), - }), - ), + Effect.orElseSucceed(() => + AiTool.of({ + name: Option.none(), + }), ), ), ); diff --git a/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts b/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts index 8576e6e19c..8a53c9c779 100644 --- a/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Layer, Option } from "effect"; import { processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { aiToolLayer } from "./ai-tool.layer.ts"; import { AiTool } from "./ai-tool.service.ts"; @@ -9,7 +9,9 @@ describe("aiToolLayer", () => { Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("codex")); - }).pipe(Effect.provide(aiToolLayer), Effect.provide(processEnvLayer({ CODEX_SANDBOX: "1" }))), + }).pipe( + Effect.provide(aiToolLayer.pipe(Layer.provide(processEnvLayer({ CODEX_SANDBOX: "1" })))), + ), ); it.live("normalizes known agent names for analytics properties", () => @@ -17,8 +19,9 @@ describe("aiToolLayer", () => { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("github_copilot")); }).pipe( - Effect.provide(aiToolLayer), - Effect.provide(processEnvLayer({ AI_AGENT: "github-copilot-cli" })), + Effect.provide( + aiToolLayer.pipe(Layer.provide(processEnvLayer({ AI_AGENT: "github-copilot-cli" }))), + ), ), ); @@ -26,6 +29,6 @@ describe("aiToolLayer", () => { Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.none()); - }).pipe(Effect.provide(aiToolLayer), Effect.provide(processEnvLayer({}))), + }).pipe(Effect.provide(aiToolLayer.pipe(Layer.provide(processEnvLayer({}))))), ); }); diff --git a/apps/cli/src/shared/telemetry/consent.integration.test.ts b/apps/cli/src/shared/telemetry/consent.integration.test.ts index ead37bef33..38e39b2b12 100644 --- a/apps/cli/src/shared/telemetry/consent.integration.test.ts +++ b/apps/cli/src/shared/telemetry/consent.integration.test.ts @@ -7,14 +7,14 @@ import { Exit, FileSystem, Fiber, + Path, PlatformError, Ref, Result, + Schema, } from "effect"; -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { TestClock } from "effect/testing"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { writeTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; @@ -25,9 +25,22 @@ const config: TelemetryConfig = { session_last_active: 1, }; -function makeDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-consent-publish-")); -} +const tempRoot = useTempWorkdir("supabase-consent-publish-"); + +const seedPreviousConfig = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = tempRoot.current; + const configPath = path.join(dir, "telemetry.json"); + yield* fs.writeFileString(configPath, "previous config"); + return { dir, configPath }; +}); + +const temporaryFiles = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.readDirectory(dir)).filter((name) => name.includes(".tmp.")); + }); function injectedFailure(pathOrDescriptor: string, code: string, cause?: unknown) { return PlatformError.systemError({ @@ -41,12 +54,9 @@ function injectedFailure(pathOrDescriptor: string, code: string, cause?: unknown } describe("writeTelemetryConfig", () => { - it.effect("retries Windows replacement failures, then publishes the config", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); - - return Effect.gen(function* () { + it.effect("retries Windows replacement failures, then publishes the config", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const calls = yield* Ref.make(0); const remainingFailures = yield* Ref.make(2); @@ -63,7 +73,7 @@ describe("writeTelemetryConfig", () => { yield* Ref.set(remainingFailures, remaining - 1); yield* Ref.update(temporaryPaths, (paths) => [...paths, from]); yield* Deferred.succeed(firstFailure, undefined); - return yield* Effect.fail(injectedFailure(to, call === 1 ? "EPERM" : "EACCES")); + return yield* injectedFailure(to, call === 1 ? "EPERM" : "EACCES"); } return yield* fs.rename(from, to); }), @@ -82,20 +92,18 @@ describe("writeTelemetryConfig", () => { expect(retries).toHaveLength(2); expect(retries[0]).toContain(".tmp."); expect(retries[1]).toBe(retries[0]); - expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual(config); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("cleans the temporary file when cancelled during replacement backoff", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(configPath), + ), + ).toEqual(config); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("cleans the temporary file when cancelled during replacement backoff", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const firstFailure = yield* Deferred.make(); const publishing = yield* writeTelemetryConfig(config, dir, "win32").pipe( @@ -110,20 +118,14 @@ describe("writeTelemetryConfig", () => { ); yield* Deferred.await(firstFailure); yield* Fiber.interrupt(publishing); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("preserves the normalized platform error when retries exhaust", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("preserves the normalized platform error when retries exhaust", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const calls = yield* Ref.make(0); const firstFailure = yield* Deferred.make(); @@ -167,20 +169,14 @@ describe("writeTelemetryConfig", () => { } } expect(yield* Ref.get(calls)).toBe(13); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("does not retry unrelated errors or failures on another platform", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("does not retry unrelated errors or failures on another platform", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; for (const [platform, code] of [ ["win32", "EINVAL"], @@ -199,21 +195,15 @@ describe("writeTelemetryConfig", () => { ); expect(Exit.isFailure(result)).toBe(true); expect(yield* Ref.get(calls)).toBe(1); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); } - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("removes a partially written temporary file when writing fails", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("removes a partially written temporary file when writing fails", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const result = yield* writeTelemetryConfig(config, dir).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -228,11 +218,8 @@ describe("writeTelemetryConfig", () => { Effect.exit, ); expect(Exit.isFailure(result)).toBe(true); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 0b34e84085..870d58d70a 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,4 +1,5 @@ import { + Crypto, Duration, Effect, FileSystem, @@ -10,7 +11,12 @@ import { Schema, } from "effect"; import { CliSettings } from "../config/cli-settings.service.ts"; -import { type ConsentState, TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { + type ConsentState, + PersistedNumberSchema, + TelemetryConfigSchema, + type TelemetryConfig, +} from "./types.ts"; export const getConfigDir = CliSettings.useSync((cliSettings) => cliSettings.supabaseHome); @@ -21,7 +27,7 @@ const LegacyTelemetryConfigSchema = Schema.Struct({ session_id: Schema.String, session_last_active: Schema.String, distinct_id: Schema.optionalKey(Schema.String), - schema_version: Schema.optionalKey(Schema.Number), + schema_version: Schema.optionalKey(PersistedNumberSchema), }); type LegacyTelemetryConfig = Schema.Schema.Type; @@ -58,11 +64,7 @@ const decodeTelemetryConfigFile = Effect.fnUntraced(function* (content: string) Effect.catch(() => Effect.gen(function* () { const legacyConfig = yield* decodeLegacyTelemetryConfigFile(content); - const config = legacyConfigToTelemetryConfig(legacyConfig); - if (config === undefined) { - return yield* Effect.fail(new Error("invalid legacy telemetry state")); - } - return config; + return yield* Effect.fromNullishOr(legacyConfigToTelemetryConfig(legacyConfig)); }), ), ); @@ -87,6 +89,7 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( configDir: string, platform: NodeJS.Platform = process.platform, ) { + const crypto = yield* Crypto.Crypto; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(configDir, { recursive: true, mode: 0o700 }); @@ -94,7 +97,7 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( // Random suffix, not a timestamp: concurrent writers (parallel test files, // two CLI processes) in the same millisecond would otherwise share a tmp // path and race the rename into ENOENT. - const tmpPath = `${configPath}.tmp.${crypto.randomUUID()}`; + const tmpPath = `${configPath}.tmp.${yield* crypto.randomUUIDv4}`; const retrySchedule = Schedule.exponential("10 millis", 2).pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.millis(100))), diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index 43bbc0e1cd..36dd647407 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -1,22 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { ConfigProvider, Effect, Layer, Option } from "effect"; +import { Clock, ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { cliSettingsLayer } from "../config/cli-settings.layer.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { mockCliProjectContext, mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; import { getEffectiveConsent, readTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; -function makeConfig(consent: TelemetryConfig["consent"]): TelemetryConfig { - return { +const makeConfig = (consent: TelemetryConfig["consent"]) => + Effect.map(Clock.currentTimeMillis, (now): TelemetryConfig => ({ consent, device_id: "test-device", session_id: "test-session", - session_last_active: Date.now(), - }; -} + session_last_active: now, + })); function withEnv(env: Record) { return cliSettingsLayer.pipe( @@ -32,58 +29,59 @@ function emptyEnv() { return withEnv({}); } -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-consent-test-")); -} +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -function writeTelemetryFile(dir: string, content: string): void { - writeFileSync(path.join(dir, "telemetry.json"), content); -} +const writeTelemetryFile = (dir: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(dir, "telemetry.json"), content); + }); describe("getEffectiveConsent", () => { - it.live("returns denied when DO_NOT_TRACK=1", () => + it.effect("returns denied when DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); - it.live("returns denied when SUPABASE_TELEMETRY_DISABLED=1", () => + it.effect("returns denied when SUPABASE_TELEMETRY_DISABLED=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); - it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over persisted granted consent", () => + it.effect("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { const consent = yield* getEffectiveConsent(Option.none()); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); - it.live("DO_NOT_TRACK=1 takes precedence over persisted granted consent", () => + it.effect("DO_NOT_TRACK=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); - it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over DO_NOT_TRACK=1", () => + it.effect("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }))), ); - it.live("returns config consent value when set", () => + it.effect("returns config consent value when set", () => Effect.gen(function* () { - expect(yield* getEffectiveConsent(Option.some(makeConfig("granted")))).toBe("granted"); - expect(yield* getEffectiveConsent(Option.some(makeConfig("denied")))).toBe("denied"); + expect(yield* getEffectiveConsent(Option.some(yield* makeConfig("granted")))).toBe("granted"); + expect(yield* getEffectiveConsent(Option.some(yield* makeConfig("denied")))).toBe("denied"); }).pipe(Effect.provide(emptyEnv())), ); - it.live("defaults to granted when no config (opt-out model)", () => + it.effect("defaults to granted when no config (opt-out model)", () => Effect.gen(function* () { const consent = yield* getEffectiveConsent(Option.none()); expect(consent).toBe("granted"); @@ -92,34 +90,33 @@ describe("getEffectiveConsent", () => { }); describe("readTelemetryConfig", () => { - it.live("decodes a valid telemetry config", () => { - const dir = makeTempDir(); - const expected = makeConfig("denied"); - writeTelemetryFile(dir, JSON.stringify(expected)); + const tempRoot = useTempWorkdir("supabase-consent-test-"); + + it.effect("decodes a valid telemetry config", () => + Effect.gen(function* () { + const dir = tempRoot.current; + const expected = yield* makeConfig("denied"); + yield* writeTelemetryFile(dir, yield* encodeJson(expected)); - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual(Option.some(expected)); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("decodes a legacy disabled telemetry state as denied consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + yield* encodeJson({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); - it.live("decodes a legacy disabled telemetry state as denied consent", () => { - const dir = makeTempDir(); - writeTelemetryFile( - dir, - JSON.stringify({ - enabled: false, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual( Option.some({ @@ -129,27 +126,24 @@ describe("readTelemetryConfig", () => { session_last_active: Date.parse("2026-04-01T12:00:00Z"), }), ); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("decodes a legacy enabled telemetry state as granted consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + yield* encodeJson({ + enabled: true, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); - it.live("decodes a legacy enabled telemetry state as granted consent", () => { - const dir = makeTempDir(); - writeTelemetryFile( - dir, - JSON.stringify({ - enabled: true, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - distinct_id: "user-123", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual( Option.some({ @@ -160,35 +154,66 @@ describe("readTelemetryConfig", () => { distinct_id: "user-123", }), ); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps an overflowed session_last_active and the persisted consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + '{"consent":"denied","device_id":"device","session_id":"session","session_last_active":1e999}', + ); - it.live("returns none for malformed JSON instead of throwing", () => { - const dir = makeTempDir(); - writeTelemetryFile(dir, ""); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "device", + session_id: "session", + session_last_active: Infinity, + }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("decodes a legacy state whose schema_version overflowed", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + '{"enabled":false,"device_id":"legacy-device","session_id":"legacy-session","session_last_active":"2026-04-01T12:00:00Z","schema_version":1e999}', + ); + + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("returns none for malformed JSON instead of throwing", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile(dir, ""); - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual(Option.none()); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns none for structurally invalid telemetry config", () => { - const dir = makeTempDir(); - writeTelemetryFile(dir, JSON.stringify({ consent: "granted" })); + it.effect("returns none for structurally invalid telemetry config", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile(dir, yield* encodeJson({ consent: "granted" })); - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual(Option.none()); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/identity.ts b/apps/cli/src/shared/telemetry/identity.ts index 92d30cd199..592ce0944d 100644 --- a/apps/cli/src/shared/telemetry/identity.ts +++ b/apps/cli/src/shared/telemetry/identity.ts @@ -1,18 +1,19 @@ -import { Effect, Option } from "effect"; +import { Clock, Crypto, Effect, Option } from "effect"; import { readTelemetryConfig, writeTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; const SESSION_TIMEOUT_MS = 30 * 60 * 1000; export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { + const crypto = yield* Crypto.Crypto; const config = yield* readTelemetryConfig(configDir); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; if (Option.isNone(config)) { const newConfig: TelemetryConfig = { consent: "granted", - device_id: crypto.randomUUID(), - session_id: crypto.randomUUID(), + device_id: yield* crypto.randomUUIDv4, + session_id: yield* crypto.randomUUIDv4, session_last_active: now, }; yield* writeTelemetryConfig(newConfig, configDir); @@ -26,7 +27,7 @@ export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { const currentConfig = config.value; const isSessionExpired = now - currentConfig.session_last_active > SESSION_TIMEOUT_MS; - const sessionId = isSessionExpired ? crypto.randomUUID() : currentConfig.session_id; + const sessionId = isSessionExpired ? yield* crypto.randomUUIDv4 : currentConfig.session_id; yield* writeTelemetryConfig( { ...currentConfig, session_id: sessionId, session_last_active: now }, @@ -86,6 +87,7 @@ export function makeTelemetryIdentity(persisted: string | undefined): TelemetryI * aliases a fresh device. */ export const resetIdentity = Effect.fnUntraced(function* (configDir: string) { + const crypto = yield* Crypto.Crypto; const identity = yield* resolveIdentity(configDir); const config = yield* readTelemetryConfig(configDir); const nextConfig: TelemetryConfig = { @@ -93,9 +95,9 @@ export const resetIdentity = Effect.fnUntraced(function* (configDir: string) { onNone: () => "granted", onSome: (value) => value.consent, }), - device_id: crypto.randomUUID(), + device_id: yield* crypto.randomUUIDv4, session_id: identity.sessionId, - session_last_active: Date.now(), + session_last_active: yield* Clock.currentTimeMillis, }; yield* writeTelemetryConfig(nextConfig, configDir); }); diff --git a/apps/cli/src/shared/telemetry/identity.unit.test.ts b/apps/cli/src/shared/telemetry/identity.unit.test.ts index cdb8dbe672..0a4889301e 100644 --- a/apps/cli/src/shared/telemetry/identity.unit.test.ts +++ b/apps/cli/src/shared/telemetry/identity.unit.test.ts @@ -1,187 +1,162 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect } from "effect"; +import { Clock, Effect, FileSystem, Path, Schema } from "effect"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { makeTelemetryIdentity, resetIdentity, resolveIdentity } from "./identity.ts"; -import type { TelemetryConfig } from "./types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-identity-test-")); -} +const TelemetryConfigJson = Schema.fromJsonString(TelemetryConfigSchema); -function writeConfig(dir: string, config: TelemetryConfig): void { - mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); -} +const writeConfig = (dir: string, config: TelemetryConfig) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "telemetry.json"), + yield* Schema.encodeEffect(TelemetryConfigJson)(config), + ); + }); -function readConfig(dir: string): TelemetryConfig { - return JSON.parse(readFileSync(path.join(dir, "telemetry.json"), "utf8")); -} +const readConfig = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const content = yield* fs.readFileString(path.join(dir, "telemetry.json")); + return yield* Schema.decodeEffect(TelemetryConfigJson)(content); + }); const fsLayer = BunServices.layer; +const tempRoot = useTempWorkdir("supabase-identity-test-"); + describe("resolveIdentity", () => { - it.live("generates new device_id on first run", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { deviceId } = yield* resolveIdentity(dir); + it.effect("generates new device_id on first run", () => + Effect.gen(function* () { + const { deviceId } = yield* resolveIdentity(tempRoot.current); expect(deviceId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); - it.live("generates new session_id on first run", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { sessionId } = yield* resolveIdentity(dir); + it.effect("generates new session_id on first run", () => + Effect.gen(function* () { + const { sessionId } = yield* resolveIdentity(tempRoot.current); expect(sessionId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); - it.live("isFirstRun is true on first call", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { isFirstRun } = yield* resolveIdentity(dir); + it.effect("isFirstRun is true on first call", () => + Effect.gen(function* () { + const { isFirstRun } = yield* resolveIdentity(tempRoot.current); expect(isFirstRun).toBe(true); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); - it.live("writes config on first run with granted consent", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { + it.effect("writes config on first run with granted consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; yield* resolveIdentity(dir); - const config = readConfig(dir); + const config = yield* readConfig(dir); expect(config.consent).toBe("granted"); expect(config.device_id).toMatch(UUID_PATTERN); expect(config.session_id).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("preserves device_id across runs", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("preserves device_id across runs", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: yield* Clock.currentTimeMillis, + }); const { deviceId } = yield* resolveIdentity(dir); expect(deviceId).toBe("existing-device-id"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("isFirstRun is false on subsequent runs", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("isFirstRun is false on subsequent runs", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: yield* Clock.currentTimeMillis, + }); const { isFirstRun } = yield* resolveIdentity(dir); expect(isFirstRun).toBe(false); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("preserves session_id within 30min", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now() - 10 * 60 * 1000, - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("preserves session_id within 30min", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: (yield* Clock.currentTimeMillis) - 10 * 60 * 1000, + }); const { sessionId } = yield* resolveIdentity(dir); expect(sessionId).toBe("existing-session-id"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("rotates session_id after 30min idle", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "old-session-id", - session_last_active: Date.now() - 31 * 60 * 1000, - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("rotates session_id after 30min idle", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "old-session-id", + session_last_active: (yield* Clock.currentTimeMillis) - 31 * 60 * 1000, + }); const { sessionId } = yield* resolveIdentity(dir); expect(sessionId).not.toBe("old-session-id"); expect(sessionId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("updates session_last_active on every call", () => { - const dir = makeTempDir(); - const before = Date.now(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now() - 5000, - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("updates session_last_active on every call", () => + Effect.gen(function* () { + const dir = tempRoot.current; + const before = yield* Clock.currentTimeMillis; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: (yield* Clock.currentTimeMillis) - 5000, + }); yield* resolveIdentity(dir); - const config = readConfig(dir); + const config = yield* readConfig(dir); expect(config.session_last_active).toBeGreaterThanOrEqual(before); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); }); describe("resetIdentity", () => { - it.live("rotates the persisted device_id and drops the distinct_id", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "old-device-id", - session_id: "session-id", - session_last_active: Date.now(), - distinct_id: "user-a", - }); - return Effect.gen(function* () { + it.effect("rotates the persisted device_id and drops the distinct_id", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "old-device-id", + session_id: "session-id", + session_last_active: yield* Clock.currentTimeMillis, + distinct_id: "user-a", + }); yield* resetIdentity(dir); - const config = readConfig(dir); + const config = yield* readConfig(dir); expect(config.distinct_id).toBeUndefined(); expect(config.device_id).not.toBe("old-device-id"); expect(config.consent).toBe("granted"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); }); describe("makeTelemetryIdentity", () => { diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index 66cfe5ea36..a3780c7b52 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Config, ConfigProvider, Effect, Layer, PlatformError } from "effect"; +import { + Config, + ConfigProvider, + Effect, + FileSystem, + Layer, + Path, + PlatformError, + Schema, +} from "effect"; import { cliSettingsLayer } from "../config/cli-settings.layer.ts"; import { TelemetryRuntime } from "./runtime.service.ts"; import { telemetryRuntimeLayer } from "./runtime.layer.ts"; @@ -13,10 +19,17 @@ import { mockTty, processEnvLayer, } from "../../../tests/helpers/mocks.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-runtime-test-")); -} +const tempRoot = useTempWorkdir("supabase-runtime-test-"); + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const telemetryConfigPath = (homeDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(homeDir, "telemetry.json"); + }); function buildLayer(opts: { homeDir: string; @@ -53,140 +66,140 @@ function buildLayer(opts: { } describe("telemetryRuntimeLayer", () => { - it.live("does not create telemetry.json when telemetry is disabled by env on first run", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("denied"); - expect(runtime.isFirstRun).toBe(false); - expect(existsSync(configPath)).toBe(false); - }).pipe( - Effect.provide( - buildLayer({ - homeDir, - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("does not create telemetry.json when telemetry is disabled by env on first run", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.isFirstRun).toBe(false); + expect(yield* fs.exists(configPath)).toBe(false); + }).pipe( + Effect.provide( + buildLayer({ + homeDir, + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + }), + ), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("marks the actual first granted invocation as first run", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("marks the actual first granted invocation as first run", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("treats a malformed telemetry.json as a fresh first run instead of crashing", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync(configPath, ""); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("treats a malformed telemetry.json as a fresh first run instead of crashing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString(configPath, ""); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("silently ignores structurally invalid telemetry.json instead of crashing", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync(configPath, JSON.stringify({ consent: "granted" })); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("silently ignores structurally invalid telemetry.json instead of crashing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString(configPath, yield* encodeJson({ consent: "granted" })); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("honors a legacy disabled telemetry state", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync( - configPath, - JSON.stringify({ - enabled: false, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("denied"); - expect(runtime.deviceId).toBe("legacy-device"); - expect(runtime.sessionId).toBe("legacy-session"); - expect(runtime.isFirstRun).toBe(false); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir, stdoutIsTty: true })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("honors a legacy disabled telemetry state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString( + configPath, + yield* encodeJson({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.deviceId).toBe("legacy-device"); + expect(runtime.sessionId).toBe("legacy-session"); + expect(runtime.isFirstRun).toBe(false); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir, stdoutIsTty: true }))); + }).pipe(Effect.provide(BunServices.layer)), + ); // `consent` is read from disk once at layer-construction time and does not reflect a later // on-disk write, so a command that rewrites telemetry.json mid-run doesn't retroactively // change what that invocation already captured. - it.live("captures consent once; a later on-disk write does not change it", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync( - configPath, - JSON.stringify({ - enabled: true, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - - // Simulates `disable` rewriting telemetry.json mid-command, after this layer already - // resolved `consent`. - yield* Effect.sync(() => - writeFileSync( + it.effect("captures consent once; a later on-disk write does not change it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString( + configPath, + yield* encodeJson({ + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + + // Simulates `disable` rewriting telemetry.json mid-command, after this layer already + // resolved `consent`. + yield* fs.writeFileString( configPath, - JSON.stringify({ + yield* encodeJson({ enabled: false, device_id: "device-123", session_id: "session-123", session_last_active: "2026-04-01T12:00:00Z", schema_version: 1, }), - ), - ); + ); - expect(runtime.consent).toBe("granted"); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + expect(runtime.consent).toBe("granted"); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/types.ts b/apps/cli/src/shared/telemetry/types.ts index 44d1823536..e96f02e5da 100644 --- a/apps/cli/src/shared/telemetry/types.ts +++ b/apps/cli/src/shared/telemetry/types.ts @@ -1,4 +1,10 @@ -import { Schema } from "effect"; +import { Predicate, Schema } from "effect"; + +/** + * Any JSON number, including an overflowed value decoded as ±Infinity, so one out-of-range field + * does not fail this schema's decode and drop the consent it reads. + */ +export const PersistedNumberSchema = Schema.declare(Predicate.isNumber); const ConsentStateSchema = Schema.Literals(["granted", "denied"] as const); export type ConsentState = Schema.Schema.Type; @@ -7,7 +13,7 @@ export const TelemetryConfigSchema = Schema.Struct({ consent: ConsentStateSchema, device_id: Schema.String, session_id: Schema.String, - session_last_active: Schema.Number, + session_last_active: PersistedNumberSchema, distinct_id: Schema.optionalKey(Schema.String), }); export type TelemetryConfig = Schema.Schema.Type; From 9001c3ae4136c64e396a2a480bcbacef29a24ab9 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:13:37 +0000 Subject: [PATCH 33/71] refactor(cli): cover `shared/telemetry` posthog transport with effect lint (CLI-2509) (#6851) ## TL;DR brings the telemetry posthog transport under the effect lint ## whats introduced? effect lint applied to the posthog client and its config: - allow list entries for `posthog-client`, `posthog-config` and their tests - the shipped posthog host and key are build defines, the same pattern `functions/serve.ts` uses, and running from source still reads them from the process env - the `fetch` handed to posthog-node runs on `HttpClient` with tracing disabled, so requests go out byte for byte as before with no extra spans or trace headers - failures, error statuses and aborts still resolve as delivered, and an already aborted signal still sends nothing - `FetchHttpClient` is provided where the analytics layer is built, in `main` and `complete` - unit and integration tests record through a test `HttpClient` instead of stubbing global `fetch`, and the e2e tests run through the effect native harness ## ref: - related: CLI-2509 --- .oxlintrc.effect.json | 6 + apps/cli/scripts/build.ts | 4 +- apps/cli/src/cli/complete.ts | 2 + apps/cli/src/cli/main.ts | 5 +- .../telemetry/telemetry.integration.test.ts | 2 + .../telemetry/failure-metadata.e2e.test.ts | 122 ++++----- .../telemetry/posthog-client.e2e.test.ts | 110 ++++---- .../src/shared/telemetry/posthog-client.ts | 82 +++++- .../telemetry/posthog-client.unit.test.ts | 249 +++++++++++++----- .../src/shared/telemetry/posthog-config.ts | 65 +++-- .../analytics.layer.integration.test.ts | 17 +- 11 files changed, 432 insertions(+), 232 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 7878484fd6..4e32b85ad9 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -23,6 +23,12 @@ "!apps/cli/src/shared/telemetry/identity.unit.test.ts", "!apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts", "!apps/cli/src/shared/telemetry/types.ts", + "!apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts", + "!apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts", + "!apps/cli/src/shared/telemetry/posthog-client.ts", + "!apps/cli/src/shared/telemetry/posthog-client.unit.test.ts", + "!apps/cli/src/shared/telemetry/posthog-config.ts", + "!apps/cli/src/shared/telemetry/posthog-config.unit.test.ts", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index a924c87494..87131b408e 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -90,8 +90,8 @@ const goSource = path.resolve(root, "apps/cli-go"); const buildDefines = { ...(await stackReleaseDefine()), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), - "process.env.SUPABASE_CLI_POSTHOG_KEY": JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), - "process.env.SUPABASE_CLI_POSTHOG_HOST": JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), + SUPABASE_CLI_POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), + SUPABASE_CLI_POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), // Skips msgpackr's startup probe for its native addon at the build host's store path, which // on macOS goes through the automounter and can hang every command (supabase/cli#6771). "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": JSON.stringify("true"), diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 314477702e..0898836706 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -1,5 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { Cause, Effect, Layer, Option } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { GlobalFlag } from "effect/unstable/cli"; import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; @@ -1117,6 +1118,7 @@ const COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; // CLI runtime tree. const completeAnalyticsLayer = analyticsLayer.pipe( Layer.provide(standaloneAnalyticsConfigLayer), + Layer.provide(FetchHttpClient.layer), Layer.provide(cliConfigProviderLayer), Layer.provide(BunServices.layer), ); diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 972d154e36..6f946e73f0 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Stdio } from "effect"; +import { Effect, Exit, Layer, Stdio } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { runCli } from "../shared/cli/run.ts"; import { upgradeNoticeHook } from "../command-internal/upgrade-notice.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; @@ -40,7 +41,7 @@ if ( )) ) { await runCli(selectedRoot, { - analyticsLayer: analyticsLayer, + analyticsLayer: analyticsLayer.pipe(Layer.provide(FetchHttpClient.layer)), afterSuccess: upgradeNoticeHook, ...(selectionCause ? { beforeParse: Effect.failCause(selectionCause) } : {}), }); diff --git a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts index 6eb6d94c75..d30c8f5f3d 100644 --- a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts +++ b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { Command } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; import { mockAnalytics, @@ -80,6 +81,7 @@ function setupWithRealAnalytics(dir: string) { Layer.provide(runtimeInfoLayer), Layer.provide(ttyLayer), Layer.provide(BunServices.layer), + Layer.provide(FetchHttpClient.layer), Layer.provide(envLayer), ); const layer = Layer.mergeAll( diff --git a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts index e3f2a246cc..43018bf949 100644 --- a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts @@ -1,55 +1,21 @@ -import { createServer, type Server } from "node:http"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Schema } from "effect"; import { gunzipSync } from "node:zlib"; -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../tests/helpers/cli.ts"; +import { runSupabaseEffect } from "../../../tests/helpers/cli.ts"; type CapturedEvent = { readonly event: unknown; readonly properties: unknown; }; -describe("failed command telemetry", () => { - let server: Server; - let host: string; - const capturedEvents: CapturedEvent[] = []; - - beforeAll(async () => { - server = createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - const body = Buffer.concat(chunks); - const decoded = request.headers["content-encoding"] === "gzip" ? gunzipSync(body) : body; - const payload: unknown = JSON.parse(decoded.toString()); - if (typeof payload === "object" && payload !== null) { - const batch = Reflect.get(payload, "batch"); - if (Array.isArray(batch)) capturedEvents.push(...batch); - } - response.writeHead(200, { "content-type": "application/json" }); - response.end("{}"); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Failed to allocate a telemetry receiver port"); - } - host = `http://127.0.0.1:${address.port}`; - }); - - afterAll(async () => { - await new Promise((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - }); - - beforeEach(() => { - capturedEvents.length = 0; - }); +class TelemetryReceiverError extends Data.TaggedError("TelemetryReceiverError")<{ + readonly cause: unknown; +}> {} +describe("failed command telemetry", () => { // `branches list` needs a syntactically valid access token so the auth gate builds and the // failure happens during project-ref resolution instead of at the auth gate itself. - test.each([ + it.live.each([ { args: ["branches", "list"], command: "branches list", @@ -83,27 +49,55 @@ describe("failed command telemetry", () => { }, rawErrors: ["failed to connect", "127.0.0.1", "select 1"], }, - ])("emits sanitized metadata from the compiled CLI ($command)", async (testCase) => { - const result = await runSupabase(testCase.args, { - env: { - SUPABASE_ACCESS_TOKEN: testCase.accessToken, - SUPABASE_TELEMETRY_DISABLED: "0", - DO_NOT_TRACK: "0", - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_failure_metadata_e2e", - SUPABASE_TELEMETRY_POSTHOG_HOST: host, - }, - }); + ])("emits sanitized metadata from the compiled CLI ($command)", (testCase) => + Effect.gen(function* () { + const capturedEvents: CapturedEvent[] = []; + const server = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + return request.arrayBuffer().then((buffer) => { + const body = new Uint8Array(buffer); + const decoded = + request.headers.get("content-encoding") === "gzip" ? gunzipSync(body) : body; + const payload: unknown = JSON.parse(new TextDecoder().decode(decoded)); + if (typeof payload === "object" && payload !== null) { + const batch = Reflect.get(payload, "batch"); + if (Array.isArray(batch)) capturedEvents.push(...batch); + } + return Response.json({}); + }); + }, + }), + catch: (cause) => new TelemetryReceiverError({ cause }), + }), + (running) => Effect.promise(() => running.stop(true)), + ); - expect(result.exitCode).toBe(1); - const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); - expect(event).toBeDefined(); - expect(event?.properties).toMatchObject({ - command: testCase.command, - exit_code: 1, - ...testCase.expected, - }); - expect(event?.properties).not.toHaveProperty("workflow"); - const encoded = JSON.stringify(event); - for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); - }); + const result = yield* runSupabaseEffect(testCase.args, { + env: { + SUPABASE_ACCESS_TOKEN: testCase.accessToken, + SUPABASE_TELEMETRY_DISABLED: "0", + DO_NOT_TRACK: "0", + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_failure_metadata_e2e", + SUPABASE_TELEMETRY_POSTHOG_HOST: server.url.origin, + }, + }); + + expect(result.exitCode).toBe(1); + const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); + expect(event).toBeDefined(); + expect(event?.properties).toMatchObject({ + command: testCase.command, + exit_code: 1, + ...testCase.expected, + }); + expect(event?.properties).not.toHaveProperty("workflow"); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(event); + for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts index c7674c3bc3..15efec285e 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts @@ -1,58 +1,62 @@ -import { createServer, type Server, type Socket } from "node:net"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../tests/helpers/cli.ts"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Deferred, Effect, Exit } from "effect"; +import { runSupabaseEffect } from "../../../tests/helpers/cli.ts"; -// A TCP blackhole: accepts connections and never responds, so telemetry requests hang until -// aborted. Asserts on the spawned process's wall-clock exit, since pending sockets keep the -// runtime alive and only actual process exit proves the telemetry exit cap holds end to end. -describe("telemetry against a blackholed PostHog endpoint", () => { - let server: Server; - let host: string; - let connections = 0; - const sockets = new Set(); - - beforeAll(async () => { - server = createServer((socket) => { - connections += 1; - sockets.add(socket); - // Aborted requests reset the connection; without a listener the - // server-side ECONNRESET becomes an uncaught exception. - socket.on("error", () => {}); - socket.on("close", () => sockets.delete(socket)); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Failed to allocate a blackhole port"); - } - host = `http://127.0.0.1:${address.port}`; - }); +class BlackholeServerError extends Data.TaggedError("BlackholeServerError")<{ + readonly cause: unknown; +}> {} - afterAll(async () => { - for (const socket of sockets) socket.destroy(); - await new Promise((resolve) => server.close(() => resolve())); - }); +// A blackholed endpoint: accepts requests and never responds while the CLI runs, so telemetry +// requests hang until aborted. Asserts on the spawned process's wall-clock exit, since pending +// sockets keep the runtime alive and only actual process exit proves the telemetry exit cap holds +// end to end. +describe("telemetry against a blackholed PostHog endpoint", () => { + it.live("commands exit promptly, cleanly, and quietly", () => + Effect.gen(function* () { + const requestArrived = yield* Deferred.make(); + const released = yield* Deferred.make(); + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const server = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + Deferred.doneUnsafe(requestArrived, Exit.void); + return runPromise( + Deferred.await(released).pipe(Effect.as(new Response(null, { status: 204 }))), + ); + }, + }), + catch: (cause) => new BlackholeServerError({ cause }), + }), + (running) => + Deferred.succeed(released, undefined).pipe( + Effect.andThen(Effect.promise(() => running.stop(true))), + ), + ); - test("commands exit promptly, cleanly, and quietly", async () => { - const startedAt = performance.now(); - const { stdout, stderr, exitCode } = await runSupabase(["telemetry", "status"], { - env: { - // spawnSupabase disables telemetry for every test by default; this - // test exists to exercise it, so turn it back on explicitly. - SUPABASE_TELEMETRY_DISABLED: "0", - DO_NOT_TRACK: "0", - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_e2e_blackhole_test", - SUPABASE_TELEMETRY_POSTHOG_HOST: host, - }, - }); - const elapsedMs = performance.now() - startedAt; + const startedAt = performance.now(); + const { stdout, stderr, exitCode } = yield* runSupabaseEffect(["telemetry", "status"], { + env: { + // spawnSupabase disables telemetry for every test by default; this + // test exists to exercise it, so turn it back on explicitly. + SUPABASE_TELEMETRY_DISABLED: "0", + DO_NOT_TRACK: "0", + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_e2e_blackhole_test", + SUPABASE_TELEMETRY_POSTHOG_HOST: server.url.origin, + }, + }); + const elapsedMs = performance.now() - startedAt; - expect(exitCode).toBe(0); - expect(stdout).toContain("Telemetry is enabled."); - expect(stderr).toBe(""); - expect(connections).toBeGreaterThanOrEqual(1); - // Healthy runs land near 2.5s (2s drain cap + spawn overhead); the nearest real failure - // signature is the SDK's 5s default deadline plus startup. - expect(elapsedMs).toBeLessThan(4_500); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Telemetry is enabled."); + expect(stderr).toBe(""); + expect(yield* Deferred.isDone(requestArrived)).toBe(true); + // Healthy runs land near 2.5s (2s drain cap + spawn overhead); the nearest real failure + // signature is the SDK's 5s default deadline plus startup. + expect(elapsedMs).toBeLessThan(4_500); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index 28d187ed01..176a923a34 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -1,27 +1,79 @@ -import { Effect } from "effect"; +import { type Context, Effect, Exit, Option, Stream } from "effect"; +import { constTrue } from "effect/Function"; +import { + Headers, + HttpBody, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; import { PostHog, type PostHogOptions } from "posthog-node"; const EXIT_DELAY_CAP_MS = 2_000; -const delivered = { +type PostHogFetch = NonNullable; +type PostHogFetchOptions = Parameters[1]; +type PostHogFetchResponse = Awaited>; + +const delivered: PostHogFetchResponse = { status: 200, text: () => Promise.resolve(""), json: () => Promise.resolve({}), }; +function toPostHogResponse( + response: HttpClientResponse.HttpClientResponse, + context: Context.Context, + signal: AbortSignal | undefined, +): PostHogFetchResponse { + const body = () => + Stream.toReadableStreamWith( + response.stream.pipe( + Stream.catchReason("HttpClientError", "EmptyBodyError", () => Stream.empty), + ), + context, + ).pipeThrough(new TransformStream(), { signal }); + return { + status: response.status, + headers: { get: (name) => Option.getOrNull(Headers.get(response.headers, name)) }, + text: () => new Response(body()).text(), + json: () => new Response(body()).json(), + get body() { + return body(); + }, + }; +} + +const sendPosthogRequest = Effect.fnUntraced(function* (url: string, options: PostHogFetchOptions) { + const client = yield* HttpClient.HttpClient; + const context = yield* Effect.context(); + const request = HttpClientRequest.make(options.method)(url).pipe( + HttpClientRequest.setBody( + options.body === undefined ? HttpBody.empty : HttpBody.raw(options.body), + ), + HttpClientRequest.setHeaders(options.headers), + ); + const response = yield* client + .execute(request) + .pipe(Effect.provideService(HttpClient.TracerDisabledWhen, constTrue)); + return response.status >= 400 ? delivered : toPostHogResponse(response, context, options.signal); +}); + // posthog-node has no logger hook: delivery failures hit hardcoded // console.error calls and multi-second retries, so report them as delivered. -export const fireAndForgetFetch: NonNullable = async (url, options) => { - try { - const response = await globalThis.fetch(url, options); - return response.status >= 400 ? delivered : response; - } catch { - return delivered; - } -}; +export function makePosthogFetch(context: Context.Context): PostHogFetch { + const runExit = Effect.runPromiseExitWith(context); + return (url, options) => + options.signal?.aborted + ? Promise.resolve(delivered) + : runExit(sendPosthogRequest(url, options), { signal: options.signal }).then( + Exit.match({ onSuccess: (response) => response, onFailure: () => delivered }), + ); +} -export const scopedPosthogClient = (apiKey: string, host: string) => - Effect.acquireRelease( +export const scopedPosthogClient = Effect.fnUntraced(function* (apiKey: string, host: string) { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const { client } = yield* Effect.acquireRelease( Effect.sync(() => { const shutdown = new AbortController(); const client = new PostHog(apiKey, { @@ -30,7 +82,7 @@ export const scopedPosthogClient = (apiKey: string, host: string) => flushInterval: 0, requestTimeout: EXIT_DELAY_CAP_MS, fetch: (url, options) => - fireAndForgetFetch(url, { + posthogFetch(url, { ...options, signal: options.signal ? AbortSignal.any([options.signal, shutdown.signal]) @@ -48,4 +100,6 @@ export const scopedPosthogClient = (apiKey: string, host: string) => // fetches lets that background drain settle without active requests. Effect.ensuring(Effect.sync(() => shutdown.abort())), ), - ).pipe(Effect.map(({ client }) => client)); + ); + return client; +}); diff --git a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts index 0f43315014..bc41356563 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -1,106 +1,229 @@ import { describe, expect, it } from "@effect/vitest"; -import { afterEach, vi } from "vitest"; -import { Effect } from "effect"; +import { Deferred, Effect, Layer, Ref } from "effect"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientError, + type HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { PostHog } from "posthog-node"; -import { fireAndForgetFetch, scopedPosthogClient } from "./posthog-client.ts"; +import { makePosthogFetch, scopedPosthogClient } from "./posthog-client.ts"; const BATCH_URL = "https://eu.i.posthog.com/batch/"; -const BATCH_OPTIONS = { method: "POST" as const, headers: {}, body: "{}" }; +const BATCH_OPTIONS = { + method: "POST" as const, + headers: { "Content-Type": "application/json" }, + body: "{}", +}; + +const respondingClient = ( + response: () => Response, + requests: Array = [], +) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, response()); + }), + ), + ); -describe("fireAndForgetFetch", () => { - afterEach(() => { - vi.unstubAllGlobals(); +describe("makePosthogFetch", () => { + it.live("sends the SDK request as given and passes successful responses through", () => { + const requests: Array = []; + return Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe(`{"status":1}`); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe(BATCH_URL); + expect(requests[0]?.headers).toEqual({ "content-type": "application/json" }); + expect(requests[0]?.body).toEqual(HttpBody.raw(BATCH_OPTIONS.body)); + }).pipe( + Effect.provide( + respondingClient(() => new Response(`{"status":1}`, { status: 200 }), requests), + ), + ); }); - it("passes successful responses through untouched", async () => { - vi.stubGlobal("fetch", async () => new Response(`{"status":1}`, { status: 200 })); - - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); - - expect(response.status).toBe(200); - expect(await response.text()).toBe(`{"status":1}`); + it.live("sends nothing when the signal is already aborted", () => { + const requests: Array = []; + return Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const response = yield* Effect.promise(() => + posthogFetch(BATCH_URL, { ...BATCH_OPTIONS, signal: AbortSignal.abort() }), + ); + + expect(response.status).toBe(200); + expect(requests).toEqual([]); + }).pipe(Effect.provide(respondingClient(() => new Response("{}"), requests))); }); - it("reports success when the network is unreachable", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("connect ECONNREFUSED"); - }); - - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); - - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - expect(await response.json()).toEqual({}); + it.live("rejects stalled body reads with the abort reason and releases their transport", () => { + const transportAborts: Array> = []; + return Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const { signal, reads } = yield* Effect.scoped( + Effect.gen(function* () { + const signal = yield* Effect.abortSignal; + const send = () => + Effect.promise(() => posthogFetch(BATCH_URL, { ...BATCH_OPTIONS, signal })); + const reads = [ + (yield* send()).text(), + (yield* send()).json(), + Promise.resolve((yield* send()).body?.getReader().read()), + ]; + return { signal, reads }; + }), + ); + + const outcomes = yield* Effect.promise(() => Promise.allSettled(reads)); + expect(outcomes).toEqual( + Array.from({ length: 3 }, () => ({ status: "rejected", reason: signal.reason })), + ); + yield* Effect.forEach(transportAborts, Deferred.await).pipe(Effect.timeout("2 seconds")); + }).pipe( + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, _url, signal) => + Effect.gen(function* () { + const aborted = yield* Deferred.make(); + signal.addEventListener("abort", () => Deferred.doneUnsafe(aborted, Effect.void), { + once: true, + }); + transportAborts.push(aborted); + return HttpClientResponse.fromWeb(request, new Response(new ReadableStream())); + }), + ), + ), + ), + ); }); - it("reports success on error responses so the SDK never retries or logs", async () => { - vi.stubGlobal( - "fetch", - async () => new Response("Proxy Authentication Required", { status: 407 }), - ); + it.live("reads a null body the way native fetch does", () => + Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const send = () => Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + const reads = [(yield* send()).text(), (yield* send()).json()]; + const nativeReads = [ + new Response(null, { status: 204 }).text(), + new Response(null, { status: 204 }).json(), + ]; + + const [text, json] = yield* Effect.promise(() => Promise.allSettled(reads)); + const [nativeText, nativeJson] = yield* Effect.promise(() => Promise.allSettled(nativeReads)); + expect(text).toEqual(nativeText); + expect(nativeText).toEqual({ status: "fulfilled", value: "" }); + expect(json).toEqual({ status: "rejected", reason: expect.any(SyntaxError) }); + expect(nativeJson).toEqual({ status: "rejected", reason: expect.any(SyntaxError) }); + }).pipe(Effect.provide(respondingClient(() => new Response(null, { status: 204 })))), + ); - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); + it.live("reports success when the network is unreachable", () => + Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe(""); + expect(yield* Effect.promise(() => response.json())).toEqual({}); + }).pipe( + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("connect ECONNREFUSED"), + }), + }), + ), + ), + ), + ), + ), + ); - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - }); + it.live("reports success on error responses so the SDK never retries or logs", () => + Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context()); + const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe(""); + }).pipe( + Effect.provide( + respondingClient(() => new Response("Proxy Authentication Required", { status: 407 })), + ), + ), + ); }); describe("scopedPosthogClient", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - it.live("captures and shuts down cleanly against an unreachable host", () => Effect.gen(function* () { const client = yield* scopedPosthogClient("phc_test", "http://127.0.0.1:9"); expect(client).toBeInstanceOf(PostHog); client.capture({ event: "verify_event", distinctId: "device-1" }); - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)), ); it.live( "bounds the whole shutdown when a request is in flight and another event is queued", () => Effect.gen(function* () { - let requestStarted = () => {}; - const firstRequestInFlight = new Promise((resolve) => { - requestStarted = resolve; - }); - let activeRequests = 0; - vi.stubGlobal( - "fetch", - (_url: string, options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - activeRequests += 1; - requestStarted(); - const abort = () => { - activeRequests -= 1; - reject(new DOMException("The operation was aborted.", "AbortError")); - }; - if (options.signal?.aborted) { - abort(); - return; - } - options.signal?.addEventListener("abort", abort); - }), + const firstRequestInFlight = yield* Deferred.make(); + const drainSettled = yield* Deferred.make(); + const activeRequests = yield* Ref.make(0); + const hangingClient = HttpClient.make(() => + Effect.acquireUseRelease( + Ref.update(activeRequests, (count) => count + 1), + () => + Deferred.succeed(firstRequestInFlight, undefined).pipe( + Effect.andThen((first) => + first ? Effect.void : Deferred.succeed(drainSettled, undefined), + ), + Effect.andThen(Effect.never), + ), + () => Ref.update(activeRequests, (count) => count - 1), + ), ); const startedAt = performance.now(); yield* Effect.gen(function* () { const client = yield* scopedPosthogClient("phc_test", "https://blackhole.invalid"); + client.on("flush", (messages: ReadonlyArray<{ readonly event: string }>) => { + if (messages.some(({ event }) => event === "second_event")) { + Deferred.doneUnsafe(drainSettled, Effect.void); + } + }); client.capture({ event: "first_event", distinctId: "device-1" }); - yield* Effect.promise(() => firstRequestInFlight); + yield* Deferred.await(firstRequestInFlight); client.capture({ event: "second_event", distinctId: "device-1" }); - }).pipe(Effect.scoped); + }).pipe(Effect.scoped, Effect.provideService(HttpClient.HttpClient, hangingClient)); expect(performance.now() - startedAt).toBeLessThan(3_000); // The SDK's drain keeps running past the shutdown deadline; without // cancellation it starts the queued request AFTER scope release and // keeps the process alive for that request's own timeout. - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(activeRequests).toBe(0); + yield* Deferred.await(drainSettled).pipe( + Effect.timeoutOrElse({ + duration: "6 seconds", + orElse: () => Effect.die(new Error("SDK drain never settled after shutdown")), + }), + ); + expect(yield* Ref.get(activeRequests)).toBe(0); }), 10_000, ); diff --git a/apps/cli/src/shared/telemetry/posthog-config.ts b/apps/cli/src/shared/telemetry/posthog-config.ts index a452eca38c..61ffe34edb 100644 --- a/apps/cli/src/shared/telemetry/posthog-config.ts +++ b/apps/cli/src/shared/telemetry/posthog-config.ts @@ -1,6 +1,9 @@ // PostHog connection config shared by the analytics layers. // Release builds inject the shipped host/key via apps/cli/scripts/build.ts. -import { Config, type ConfigProvider, Effect, Option } from "effect"; +import { Config, ConfigProvider, Effect, Option } from "effect"; + +declare const SUPABASE_CLI_POSTHOG_HOST: string | undefined; +declare const SUPABASE_CLI_POSTHOG_KEY: string | undefined; const DEFAULT_HOST = "https://eu.i.posthog.com"; @@ -9,40 +12,50 @@ export interface PosthogConfig { readonly key: Option.Option; } -function nonEmptyString(value: string | undefined): Option.Option { - return value === undefined || value === "" ? Option.none() : Option.some(value); -} - -function shippedPosthogHost(): Option.Option { - return nonEmptyString(process.env.SUPABASE_CLI_POSTHOG_HOST); +function nonEmptyString(value: string): Option.Option { + return value === "" ? Option.none() : Option.some(value); } -function shippedPosthogKey(): Option.Option { - return nonEmptyString(process.env.SUPABASE_CLI_POSTHOG_KEY); +function readNonEmptyString( + provider: ConfigProvider.ConfigProvider, + name: string, +): Effect.Effect, Config.ConfigError> { + return Config.option(Config.string(name)) + .parse(provider) + .pipe(Effect.map(Option.flatMap(nonEmptyString))); } -function resolvePosthogConfigValues( - host: Option.Option, - key: Option.Option, -): PosthogConfig { - return { - host: Option.getOrElse(Option.orElse(host, shippedPosthogHost), () => DEFAULT_HOST), - key: Option.orElse(key, shippedPosthogKey), - }; +function readShippedValue( + injected: string | undefined, + name: string, +): Effect.Effect, Config.ConfigError> { + return injected === undefined + ? Effect.suspend(() => readNonEmptyString(ConfigProvider.fromEnv(), name)) + : Effect.succeed(nonEmptyString(injected)); } export function resolvePosthogConfig( provider: ConfigProvider.ConfigProvider, ): Effect.Effect { return Effect.gen(function* () { - const host = Option.filter( - yield* Config.option(Config.string("SUPABASE_TELEMETRY_POSTHOG_HOST")).parse(provider), - (value) => value.length > 0, - ); - const key = Option.filter( - yield* Config.option(Config.string("SUPABASE_TELEMETRY_POSTHOG_KEY")).parse(provider), - (value) => value.length > 0, - ); - return resolvePosthogConfigValues(host, key); + const host = yield* readNonEmptyString(provider, "SUPABASE_TELEMETRY_POSTHOG_HOST"); + const key = yield* readNonEmptyString(provider, "SUPABASE_TELEMETRY_POSTHOG_KEY"); + return { + host: Option.getOrElse( + Option.isSome(host) + ? host + : yield* readShippedValue( + typeof SUPABASE_CLI_POSTHOG_HOST === "string" ? SUPABASE_CLI_POSTHOG_HOST : undefined, + "SUPABASE_CLI_POSTHOG_HOST", + ), + () => DEFAULT_HOST, + ), + key: Option.isSome(key) + ? key + : yield* readShippedValue( + typeof SUPABASE_CLI_POSTHOG_KEY === "string" ? SUPABASE_CLI_POSTHOG_KEY : undefined, + "SUPABASE_CLI_POSTHOG_KEY", + ), + }; }); } diff --git a/apps/cli/src/telemetry/analytics.layer.integration.test.ts b/apps/cli/src/telemetry/analytics.layer.integration.test.ts index 4c2c3e6fc1..75e9ae1d0d 100644 --- a/apps/cli/src/telemetry/analytics.layer.integration.test.ts +++ b/apps/cli/src/telemetry/analytics.layer.integration.test.ts @@ -1,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect, Layer, Option } from "effect"; -import { vi } from "vitest"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { mockRuntimeInfo, mockTty } from "../../tests/helpers/mocks.ts"; @@ -12,14 +12,14 @@ import { analyticsLayer } from "./analytics.layer.ts"; const tempRoot = useTempWorkdir("supabase-analytics-destination-"); describe("analytics destination", () => { - afterEach(() => vi.unstubAllGlobals()); - it.live("uses ambient telemetry configuration despite project destination settings", () => { const destinations: string[] = []; - vi.stubGlobal("fetch", async (url: string) => { - destinations.push(String(url)); - return Response.json({ status: 1 }); - }); + const recordingClient = HttpClient.make((request, url) => + Effect.sync(() => { + destinations.push(url.toString()); + return HttpClientResponse.fromWeb(request, Response.json({ status: 1 })); + }), + ); const settings = Layer.succeed(CliSettings, { apiUrl: "https://api.supabase.com", dashboardUrl: "https://supabase.com/dashboard", @@ -39,6 +39,7 @@ describe("analytics destination", () => { Layer.provide(mockRuntimeInfo({ homeDir: tempRoot.current })), Layer.provide(mockTty({ stdoutIsTty: false })), Layer.provide(BunServices.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, recordingClient)), ); return Effect.gen(function* () { yield* Analytics.use((analytics) => analytics.capture("destination_test")).pipe( From 7fa68d3e2bc0b9d44387c99ce06a4147de7b29f1 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:13:38 +0000 Subject: [PATCH 34/71] refactor(cli): cover `shared/telemetry` tests with effect lint (CLI-2509) (#6853) ## TL;DR brings the remaining telemetry tests under the effect lint, which completes the `telemetry` area ## whats introduced? effect lint applied to the rest of the telemetry tests: - the per file allow list entries collapse into one `shared/telemetry/**` entry - tracing and exporter tests write fixtures through `FileSystem` and read time through `Clock` - instrumentation tests provide their layers once instead of chaining `Effect.provide` - the error tag scan walks the source through `FileSystem` and reads its update flag through `Config`, and the `error-tags.txt` snapshot is unchanged - the plain error fixtures extend `Data.Error`, which still takes the classifier's untagged path ## ref: - closes: CLI-2509 --- .oxlintrc.effect.json | 16 +- .../telemetry/analytics-context.unit.test.ts | 6 +- .../command-instrumentation.unit.test.ts | 142 +++-- ...actionability-minified.integration.test.ts | 96 ++-- .../error-actionability.unit.test.ts | 13 +- .../error-tag-stability.unit.test.ts | 190 +++--- .../exporters/debug-console.unit.test.ts | 139 ++--- .../telemetry/exporters/ndjson.unit.test.ts | 24 +- .../telemetry/tracing.layer.unit.test.ts | 542 +++++++++--------- 9 files changed, 591 insertions(+), 577 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 4e32b85ad9..1c421644d2 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -14,21 +14,7 @@ "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", "!apps/cli/src/shared/runtime/file-watcher.service.ts", - "!apps/cli/src/shared/telemetry/ai-tool.layer.ts", - "!apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts", - "!apps/cli/src/shared/telemetry/consent.integration.test.ts", - "!apps/cli/src/shared/telemetry/consent.ts", - "!apps/cli/src/shared/telemetry/consent.unit.test.ts", - "!apps/cli/src/shared/telemetry/identity.ts", - "!apps/cli/src/shared/telemetry/identity.unit.test.ts", - "!apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts", - "!apps/cli/src/shared/telemetry/types.ts", - "!apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts", - "!apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts", - "!apps/cli/src/shared/telemetry/posthog-client.ts", - "!apps/cli/src/shared/telemetry/posthog-client.unit.test.ts", - "!apps/cli/src/shared/telemetry/posthog-config.ts", - "!apps/cli/src/shared/telemetry/posthog-config.unit.test.ts", + "!apps/cli/src/shared/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", diff --git a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts index ed81a5fb20..f51280a769 100644 --- a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts +++ b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts @@ -43,11 +43,7 @@ describe("withAnalyticsContext", () => { it.live("is inherited by child fibers", () => Effect.gen(function* () { const child = yield* Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - Effect.gen(function* () { - return yield* CurrentAnalyticsContext; - }), - ); + const fiber = yield* Effect.forkChild(CurrentAnalyticsContext); return yield* Fiber.join(fiber); }).pipe( withAnalyticsContext({ diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts index 10cb1dd66f..6f8a3f26fe 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Data, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { Cause, Data, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; @@ -30,6 +30,8 @@ const FAILURE_PROPERTY_NAMES = [ PropWorkflow, ] as const; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + class InstrumentationAuthError extends Data.TaggedError("InstrumentationAuthError")<{ readonly message: string; readonly path: string; @@ -107,14 +109,14 @@ describe("withCommandInstrumentation", () => { expect(typeof span.attributes.get("command_run_id")).toBe("string"); }).pipe( withCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["branches", "list"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), + commandRuntimeLayer(["branches", "list"]), + ), ), - Effect.provide(commandRuntimeLayer(["branches", "list"])), ); }); @@ -130,14 +132,14 @@ describe("withCommandInstrumentation", () => { }); }).pipe( withCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["start", "--detach", "--exclude=auth"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["start", "--detach", "--exclude=auth"]) }), + commandRuntimeLayer(["start"]), + ), ), - Effect.provide(commandRuntimeLayer(["start"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(2); @@ -174,17 +176,17 @@ describe("withCommandInstrumentation", () => { const failure = new InstrumentationAuthError(secrets); const program = withCommandInstrumentation()(Effect.fail(failure)).pipe( - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["login"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]), + ), ), - Effect.provide(commandRuntimeLayer(["login"])), Effect.exit, Effect.tap((exit) => - Effect.sync(() => { + Effect.gen(function* () { expect(analytics.captured).toHaveLength(1); const event = analytics.captured[0]; expect(event?.event).toBe("cli_command_executed"); @@ -198,7 +200,7 @@ describe("withCommandInstrumentation", () => { suggested_command: "supabase login", }); expect(event?.properties).not.toHaveProperty(PropWorkflow); - const encoded = JSON.stringify(event); + const encoded = yield* encodeJson(event); for (const secret of Object.values(secrets)) expect(encoded).not.toContain(secret); expect(Exit.isFailure(exit)).toBe(true); @@ -218,13 +220,17 @@ describe("withCommandInstrumentation", () => { return Effect.die(new TypeError(secret)).pipe( withCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) })), - Effect.provide(commandRuntimeLayer(["branches", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), + commandRuntimeLayer(["branches", "list"]), + ), + ), Effect.exit, Effect.tap(() => - Effect.sync(() => { + Effect.gen(function* () { expect(analytics.captured[0]?.properties).toMatchObject({ exit_code: 1, error_kind: "internal_bug", @@ -233,7 +239,7 @@ describe("withCommandInstrumentation", () => { has_suggestion: true, suggestion_type: "rerun_debug", }); - expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); + expect(yield* encodeJson(analytics.captured[0])).not.toContain(secret); }), ), Effect.asVoid, @@ -252,10 +258,14 @@ describe("withCommandInstrumentation", () => { return Effect.fail(failure).pipe( withCommandInstrumentation(), - Effect.provide(failingAnalytics(new Error("telemetry defect"))), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), - Effect.provide(commandRuntimeLayer(["login"])), + Effect.provide( + Layer.mergeAll( + failingAnalytics(new Error("telemetry defect")), + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -276,10 +286,14 @@ describe("withCommandInstrumentation", () => { // is being cancelled and swallowing would fight the cancellation. return Effect.void.pipe( withCommandInstrumentation(), - Effect.provide(interruptingAnalytics()), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), - Effect.provide(commandRuntimeLayer(["login"])), + Effect.provide( + Layer.mergeAll( + interruptingAnalytics(), + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -307,22 +321,24 @@ describe("withCommandInstrumentation", () => { }, allowedFlagValues: ["exclude", "mode", "stack"], }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "start", - "--detach", - "--mode=docker", - "--exclude", - "auth", - "--exclude", - "storage", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "start", + "--detach", + "--mode=docker", + "--exclude", + "auth", + "--exclude", + "storage", + ]), + }), + commandRuntimeLayer(["start"]), + ), ), - Effect.provide(commandRuntimeLayer(["start"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -352,14 +368,16 @@ describe("withCommandInstrumentation", () => { }, allowedFlagValues: ["token", "name", "noBrowser"], }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), + }), + commandRuntimeLayer(["login"]), + ), ), - Effect.provide(commandRuntimeLayer(["login"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -376,16 +394,16 @@ describe("withCommandInstrumentation", () => { it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["telemetry", "enable"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) }), + commandRuntimeLayer(["telemetry", "enable"]), + ), ), - Effect.provide(commandRuntimeLayer(["telemetry", "enable"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toEqual([]); diff --git a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts index f1fc502b85..80b9c8549e 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts @@ -1,38 +1,48 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import { randomUUID } from "node:crypto"; import { pathToFileURL } from "node:url"; -import { describe, expect, test } from "vitest"; + +const encodeSpecifier = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); describe("release-minified error fingerprints", () => { - test("keeps a declared tagged error's source identifier", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-error-actionability-")); - const bundlePath = join(tempDir, "fixture.mjs"); - const errorModule = resolve(import.meta.dirname, "../functions/delete.errors.ts"); - const plainErrorModule = resolve( - import.meta.dirname, - "../../command-internal/config-validate.ts", - ); - const classifierModule = resolve(import.meta.dirname, "error-actionability.ts"); + it.live("keeps a declared tagged error's source identifier", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-error-actionability-", + }); + const bundlePath = path.join(tempDir, "fixture.mjs"); + const errorModule = yield* encodeSpecifier( + path.resolve(import.meta.dirname, "../functions/delete.errors.ts"), + ); + const plainErrorModule = yield* encodeSpecifier( + path.resolve(import.meta.dirname, "../../command-internal/config-validate.ts"), + ); + const classifierModule = yield* encodeSpecifier( + path.resolve(import.meta.dirname, "error-actionability.ts"), + ); - try { - const build = await Bun.build({ - entrypoints: ["actionability-fixture"], - target: "bun", - minify: true, - plugins: [ - { - name: "actionability-fixture", - setup(builder) { - builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ - path: "actionability-fixture", - namespace: "actionability-fixture", - })); - builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ - contents: ` - import { InvalidFunctionSlugError } from ${JSON.stringify(errorModule)}; - import { ConfigValidateError } from ${JSON.stringify(plainErrorModule)}; - import { classifyCliErrorActionability } from ${JSON.stringify(classifierModule)}; + const build = yield* Effect.tryPromise(() => + Bun.build({ + entrypoints: ["actionability-fixture"], + target: "bun", + minify: true, + plugins: [ + { + name: "actionability-fixture", + setup(builder) { + builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ + path: "actionability-fixture", + namespace: "actionability-fixture", + })); + builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ + contents: ` + import { InvalidFunctionSlugError } from ${errorModule}; + import { ConfigValidateError } from ${plainErrorModule}; + import { classifyCliErrorActionability } from ${classifierModule}; export const taggedConstructorName = InvalidFunctionSlugError.name; export const taggedClassification = classifyCliErrorActionability( new InvalidFunctionSlugError({ message: "private user input" }), @@ -42,12 +52,13 @@ describe("release-minified error fingerprints", () => { new ConfigValidateError("private user input"), ); `, - loader: "ts", - })); + loader: "ts", + })); + }, }, - }, - ], - }); + ], + }), + ); expect(build.success, build.logs.map(String).join("\n")).toBe(true); expect(build.outputs).toHaveLength(1); @@ -55,8 +66,11 @@ describe("release-minified error fingerprints", () => { expect(output).toBeDefined(); if (output === undefined) return; - await Bun.write(bundlePath, output); - const fixture = await import(`${pathToFileURL(bundlePath).href}?run=${crypto.randomUUID()}`); + const bundle = yield* Effect.tryPromise(() => output.arrayBuffer()); + yield* fs.writeFile(bundlePath, new Uint8Array(bundle)); + const fixture = yield* Effect.tryPromise( + () => import(`${pathToFileURL(bundlePath).href}?run=${randomUUID()}`), + ); expect(Reflect.get(fixture, "taggedConstructorName")).not.toBe("InvalidFunctionSlugError"); expect(Reflect.get(fixture, "taggedClassification")).toEqual({ error_kind: "user_actionable", @@ -73,8 +87,6 @@ describe("release-minified error fingerprints", () => { has_suggestion: true, suggestion_type: "update_config", }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index b6d773b839..e0508fd99f 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -32,7 +32,7 @@ class DeclaredStatusError extends Data.TaggedError("DeclaredStatusError")<{ } } -class PlainDeclaredError extends Error { +class PlainDeclaredError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "PlainDeclaredError"; get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -90,9 +90,10 @@ describe("classifyCliErrorActionability", () => { suggestion_type: "login", suggested_command: "supabase login", }); - expect(classifyCliErrorActionability(new PlainDeclaredError("private")).error_fingerprint).toBe( - "error:PlainDeclaredError", - ); + expect( + classifyCliErrorActionability(new PlainDeclaredError({ message: "private" })) + .error_fingerprint, + ).toBe("error:PlainDeclaredError"); }); it("preserves native Error subclass identifiers after minification", () => { @@ -141,7 +142,7 @@ describe("classifyCliErrorActionability", () => { }); it("rejects malformed declarations and arbitrary remediation text", () => { - class InvalidDeclaration extends Error { + class InvalidDeclaration extends Data.Error { get [ErrorActionabilityId]() { return { error_kind: "user_actionable", @@ -159,7 +160,7 @@ describe("classifyCliErrorActionability", () => { it("handles hostile declarations and unknown failures safely", () => { const secret = "customer-project-ref"; - class HostileError extends Error { + class HostileError extends Data.Error { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { throw new Error(secret); } diff --git a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts index 5eefce133c..5b4a4e3620 100644 --- a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts @@ -1,7 +1,7 @@ -import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; +import { Config, Effect, FileSystem, Option, Path, PlatformError } from "effect"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { beforeAll, describe, expect, it } from "vitest"; /** * Guards the telemetry identity of every CLI error: the string passed to @@ -21,9 +21,10 @@ import { beforeAll, describe, expect, it } from "vitest"; const cliSrcDir = fileURLToPath(new URL("../..", import.meta.url)); const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); -const externalConfigSrcDir = path.join(repoRoot, "packages/config/src"); -const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); -const fixturePath = path.join(fixturesDir, "error-tags.txt"); +const externalConfigSrcDir = fileURLToPath( + new URL("../../../../../packages/config/src", import.meta.url), +); +const fixturePath = fileURLToPath(new URL("./__fixtures__/error-tags.txt", import.meta.url)); // Matches both the inline declaration form and the formatter-wrapped // multi-line form, where the string literal lands on its own line before the @@ -59,15 +60,24 @@ interface TaggedErrorDeclaration { readonly file: string; } -function walk(dir: string): Array { - return readdirSync(dir).flatMap((entry) => { - if (entry === "__fixtures__") return []; - const fullPath = path.join(dir, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) return walk(fullPath); - return fullPath.endsWith(".ts") && !fullPath.endsWith(".d.ts") ? [fullPath] : []; +const walk = ( + dir: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(dir); + const nested = yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + if (entry === "__fixtures__") return []; + const fullPath = path.join(dir, entry); + const stats = yield* fs.stat(fullPath); + if (stats.type === "Directory") return yield* walk(fullPath); + return fullPath.endsWith(".ts") && !fullPath.endsWith(".d.ts") ? [fullPath] : []; + }), + ); + return nested.flat(); }); -} function isProductionSourceFile(filePath: string): boolean { return !TEST_FILE_SUFFIXES.some((suffix) => filePath.endsWith(suffix)); @@ -78,25 +88,25 @@ function isProductionSourceFile(filePath: string): boolean { * given production files, skipping `COMPUTED_TAG_SOURCE_FILES` (those come from * {@link collectComputedTagDeclarations} instead) so a file never contributes twice. */ -function collectStaticDeclarations( - filePaths: ReadonlyArray, - rootDir: string, -): Array { - const declarations: Array = []; - for (const filePath of filePaths) { - const relativeToRoot = path.relative(rootDir, filePath); - if (rootDir === cliSrcDir && computedTagSourceFileSet.has(relativeToRoot)) continue; - const source = readFileSync(filePath, "utf8"); - for (const match of source.matchAll(TAGGED_ERROR_PATTERN)) { - declarations.push({ - className: match[1]!, - tag: match[2]!, - file: path.relative(repoRoot, filePath), - }); +const collectStaticDeclarations = (filePaths: ReadonlyArray, rootDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const declarations: Array = []; + for (const filePath of filePaths) { + const relativeToRoot = path.relative(rootDir, filePath); + if (rootDir === cliSrcDir && computedTagSourceFileSet.has(relativeToRoot)) continue; + const source = yield* fs.readFileString(filePath); + for (const match of source.matchAll(TAGGED_ERROR_PATTERN)) { + declarations.push({ + className: match[1]!, + tag: match[2]!, + file: path.relative(repoRoot, filePath), + }); + } } - } - return declarations; -} + return declarations; + }); /** * Runtime half of the computed-tag guard: imports each `COMPUTED_TAG_SOURCE_FILES` module, @@ -106,26 +116,26 @@ function collectStaticDeclarations( * `new Export({})` is safe because every export here is a `Data.TaggedError`-derived class * whose generated constructor assigns whatever properties are present without validating them. */ -async function collectComputedTagDeclarations(): Promise> { +const collectComputedTagDeclarations = Effect.gen(function* () { + const path = yield* Path.Path; const declarations: Array = []; for (const relativeFile of COMPUTED_TAG_SOURCE_FILES) { const moduleUrl = pathToFileURL(path.join(cliSrcDir, relativeFile)).href; - const module: Record = await import(moduleUrl); + const module: Record = yield* Effect.tryPromise(() => import(moduleUrl)); for (const [exportName, exportValue] of Object.entries(module)) { if (typeof exportValue !== "function") continue; - let tag: unknown; - try { + const constructed = yield* Effect.try(() => { const Ctor = exportValue as new (args: Record) => { _tag?: unknown }; - tag = new Ctor({})._tag; - } catch { - continue; // Not a constructible Data.TaggedError-shaped export. - } + return new Ctor({})._tag; + }).pipe(Effect.option); + if (Option.isNone(constructed)) continue; // Not a constructible Data.TaggedError-shaped export. + const tag = constructed.value; if (typeof tag !== "string") continue; declarations.push({ className: exportName, tag, file: `apps/cli/src/${relativeFile}` }); } } return declarations; -} +}); /** Shared comparator for the fixture and the live scan, so a `localeCompare`-vs-default-sort * mismatch can't make the comparison lie. */ @@ -133,23 +143,22 @@ function compareTags(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } -function readSnapshotTags(): Array { - return readFileSync(fixturePath, "utf8") - .split(/\r?\n/) - .filter((line) => line.length > 0); -} +const readSnapshotTags = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.readFileString(fixturePath)).split(/\r?\n/).filter((line) => line.length > 0); +}); /** Every production `Data.TaggedError` declaration, static and computed alike. */ -async function collectProductionDeclarations(): Promise> { - const cliFiles = walk(cliSrcDir).filter(isProductionSourceFile); - const externalFiles = walk(externalConfigSrcDir).filter(isProductionSourceFile); +const collectProductionDeclarations = Effect.gen(function* () { + const cliFiles = (yield* walk(cliSrcDir)).filter(isProductionSourceFile); + const externalFiles = (yield* walk(externalConfigSrcDir)).filter(isProductionSourceFile); const staticDeclarations = [ - ...collectStaticDeclarations(cliFiles, cliSrcDir), - ...collectStaticDeclarations(externalFiles, externalConfigSrcDir), + ...(yield* collectStaticDeclarations(cliFiles, cliSrcDir)), + ...(yield* collectStaticDeclarations(externalFiles, externalConfigSrcDir)), ]; - const computedDeclarations = await collectComputedTagDeclarations(); + const computedDeclarations = yield* collectComputedTagDeclarations; return [...staticDeclarations, ...computedDeclarations]; -} +}); /** * Regenerates `__fixtures__/error-tags.txt` from the current, live tag set. @@ -160,44 +169,53 @@ async function collectProductionDeclarations(): Promise): void { - const sorted = [...tags].sort(compareTags); - writeFileSync(fixturePath, sorted.map((tag) => `${tag}\n`).join("")); -} +const writeFixture = (tags: ReadonlySet) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sorted = [...tags].sort(compareTags); + yield* fs.writeFileString(fixturePath, sorted.map((tag) => `${tag}\n`).join("")); + }); describe("error tag stability", () => { let productionDeclarations: Array; - beforeAll(async () => { - productionDeclarations = await collectProductionDeclarations(); - }); - - it("keeps every Data.TaggedError tag literal identical to the committed snapshot", () => { - const currentTagSet = new Set(productionDeclarations.map((declaration) => declaration.tag)); - - if (process.env["UPDATE_ERROR_TAGS_FIXTURE"] === "1") { - writeFixture(currentTagSet); - } - - const currentTags = [...currentTagSet].sort(compareTags); - const snapshotTags = readSnapshotTags(); - - const added = currentTags.filter((tag) => !snapshotTags.includes(tag)); - const removed = snapshotTags.filter((tag) => !currentTags.includes(tag)); + beforeAll(() => + Effect.runPromise(collectProductionDeclarations.pipe(Effect.provide(BunServices.layer))).then( + (declarations) => { + productionDeclarations = declarations; + }, + ), + ); + + it.live("keeps every Data.TaggedError tag literal identical to the committed snapshot", () => + Effect.gen(function* () { + const currentTagSet = new Set(productionDeclarations.map((declaration) => declaration.tag)); + + const updateFixture = yield* Config.option(Config.string("UPDATE_ERROR_TAGS_FIXTURE")); + if (Option.isSome(updateFixture) && updateFixture.value === "1") { + yield* writeFixture(currentTagSet); + } - expect( - { added, removed }, - 'A Data.TaggedError("...") tag literal was added, removed, or changed. That string is the ' + - "error's telemetry identity in PostHog -- it flows into error_fingerprint as `tag:` on " + - "the cli_command_executed event. Changing it silently splits one error's history into two " + - "fingerprints, with no error and no warning, breaking repeat-rate and trend continuity. " + - "Renaming the error CLASS is fine; do NOT change the string literal passed to " + - "Data.TaggedError(...) when you do it. If this change is an intentional, reviewed identity " + - "change (not an accidental rename), regenerate the snapshot with " + - '`UPDATE_ERROR_TAGS_FIXTURE=1 bun --bun vitest run --project unit -t "error tag stability"` ' + - "and commit the resulting apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt.", - ).toEqual({ added: [], removed: [] }); - }); + const currentTags = [...currentTagSet].sort(compareTags); + const snapshotTags = yield* readSnapshotTags; + + const added = currentTags.filter((tag) => !snapshotTags.includes(tag)); + const removed = snapshotTags.filter((tag) => !currentTags.includes(tag)); + + expect( + { added, removed }, + 'A Data.TaggedError("...") tag literal was added, removed, or changed. That string is the ' + + "error's telemetry identity in PostHog -- it flows into error_fingerprint as `tag:` on " + + "the cli_command_executed event. Changing it silently splits one error's history into two " + + "fingerprints, with no error and no warning, breaking repeat-rate and trend continuity. " + + "Renaming the error CLASS is fine; do NOT change the string literal passed to " + + "Data.TaggedError(...) when you do it. If this change is an intentional, reviewed identity " + + "change (not an accidental rename), regenerate the snapshot with " + + '`UPDATE_ERROR_TAGS_FIXTURE=1 bun --bun vitest run --project unit -t "error tag stability"` ' + + "and commit the resulting apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt.", + ).toEqual({ added: [], removed: [] }); + }).pipe(Effect.provide(BunServices.layer)), + ); it("never lets two unexpectedly-different error classes share the same tag literal", () => { /** diff --git a/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts b/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts index 644774b0e6..6d5482dffd 100644 --- a/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts +++ b/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts @@ -1,77 +1,86 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Context, Effect, Exit, Option, Schema, Tracer } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Clock, Context, Effect, Exit, Option, Schema, Tracer } from "effect"; import { formatSpanForDebugConsole, makeDebugConsoleExporter } from "./debug-console.ts"; -function makeEndedSpan(name: string, attrs: Record = {}): Tracer.Span { - const startTime = BigInt(Date.now()) * BigInt(1_000_000); - const endTime = startTime + BigInt(50_000_000); // 50ms later - const attributes = new Map(Object.entries(attrs)); - return { - _tag: "Span", - name, - spanId: "abc123", - traceId: "def456", - parent: Option.none(), - annotations: Context.empty(), - links: [], - sampled: true, - kind: "internal", - status: { - _tag: "Ended", - startTime, - endTime, - exit: { _tag: "Success", value: undefined } as any, - }, - attributes, - end: () => {}, - attribute: () => {}, - event: () => {}, - addLinks: () => {}, - }; -} +const makeEndedSpan = (name: string, attrs: Record = {}) => + Effect.map(Clock.currentTimeMillis, (now): Tracer.Span => { + const startTime = BigInt(now) * BigInt(1_000_000); + const endTime = startTime + BigInt(50_000_000); // 50ms later + const attributes = new Map(Object.entries(attrs)); + return { + _tag: "Span", + name, + spanId: "abc123", + traceId: "def456", + parent: Option.none(), + annotations: Context.empty(), + links: [], + sampled: true, + kind: "internal", + status: { + _tag: "Ended", + startTime, + endTime, + exit: { _tag: "Success", value: undefined } as any, + }, + attributes, + end: () => {}, + attribute: () => {}, + event: () => {}, + addLinks: () => {}, + }; + }); describe("debug-console exporter", () => { - test("formats and writes ended span info", () => { - let stderrOutput = ""; - const span = makeEndedSpan("test-span", { command: "login" }); - const exportSpanToDebugConsole = makeDebugConsoleExporter((line) => - Effect.sync(() => { - stderrOutput += line; - }), - ); + it.effect("formats and writes ended span info", () => + Effect.gen(function* () { + let stderrOutput = ""; + const span = yield* makeEndedSpan("test-span", { command: "login" }); + const exportSpanToDebugConsole = makeDebugConsoleExporter((line) => + Effect.sync(() => { + stderrOutput += line; + }), + ); - Effect.runSync(exportSpanToDebugConsole(span)); + yield* exportSpanToDebugConsole(span); - expect(stderrOutput).toContain("test-span"); - expect(stderrOutput).toContain("50ms"); - expect(stderrOutput).toContain("login"); - expect(stderrOutput).toContain("\n"); - }); + expect(stderrOutput).toContain("test-span"); + expect(stderrOutput).toContain("50ms"); + expect(stderrOutput).toContain("login"); + expect(stderrOutput).toContain("\n"); + }), + ); - test("returns undefined for spans that have not ended", () => { - const span = { - ...makeEndedSpan("pending-span"), - status: { - _tag: "Started", - startTime: BigInt(Date.now()) * BigInt(1_000_000), - } as Tracer.SpanStatus, - }; + it.effect("returns undefined for spans that have not ended", () => + Effect.gen(function* () { + const span = { + ...(yield* makeEndedSpan("pending-span")), + status: { + _tag: "Started", + startTime: BigInt(yield* Clock.currentTimeMillis) * BigInt(1_000_000), + } as Tracer.SpanStatus, + }; - expect(Effect.runSync(formatSpanForDebugConsole(span))).toEqual(Option.none()); - }); + expect(yield* formatSpanForDebugConsole(span)).toEqual(Option.none()); + }), + ); - test("returns a typed failure for unserializable attributes", () => { - const cyclic: Record = {}; - cyclic.self = cyclic; - const result = Effect.runSyncExit(formatSpanForDebugConsole(makeEndedSpan("cyclic", cyclic))); + it.effect("returns a typed failure for unserializable attributes", () => + Effect.gen(function* () { + const cyclic: Record = {}; + cyclic.self = cyclic; + const result = yield* Effect.exit( + formatSpanForDebugConsole(yield* makeEndedSpan("cyclic", cyclic)), + ); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Cause.findErrorOption(result.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(Schema.isSchemaError(error.value)).toBe(true); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(Schema.isSchemaError(error.value)).toBe(true); + } } - } - }); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts b/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts index deb9649327..ef739c80ed 100644 --- a/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts +++ b/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts @@ -1,22 +1,18 @@ import { describe, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect } from "effect"; +import { Effect, Path } from "effect"; +import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { initNdjsonExporter } from "./ndjson.ts"; const fsLayer = BunServices.layer; describe("initNdjsonExporter", () => { - it.live("does not fail when traces directory does not exist", () => { - const dir = mkdtempSync(path.join(tmpdir(), "supabase-ndjson-test-")); - const tracesDir = path.join(dir, "traces"); - return Effect.gen(function* () { - yield* initNdjsonExporter(tracesDir); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + const tempRoot = useTempWorkdir("supabase-ndjson-test-"); + + it.live("does not fail when traces directory does not exist", () => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* initNdjsonExporter(path.join(tempRoot.current, "traces")); + }).pipe(Effect.provide(fsLayer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts index a3d9ea9b4c..b6eaa097db 100644 --- a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts @@ -1,17 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { + Clock, ConfigProvider, Context, Deferred, @@ -22,27 +12,58 @@ import { Fiber, Layer, Option, + Path, PlatformError, + Schema, Sink, Stdio, Tracer, } from "effect"; import { TestClock } from "effect/testing"; import { CliSettings } from "../config/cli-settings.service.ts"; -import type { TelemetryConfig } from "./types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { mockCliProjectContext, mockRuntimeInfo, mockTty } from "../../../tests/helpers/mocks.ts"; import { tracingLayer } from "./tracing.layer.ts"; const fsLayer = BunServices.layer; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-tracing-test-")); -} +const tempRoot = useTempWorkdir("supabase-tracing-test-"); -function writeConfig(dir: string, config: TelemetryConfig): void { - mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); -} +const TelemetryConfigJson = Schema.fromJsonString(TelemetryConfigSchema); + +const writeConfig = (dir: string, config: TelemetryConfig) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "telemetry.json"), + yield* Schema.encodeEffect(TelemetryConfigJson)(config), + ); + }); + +const readConfig = (configPath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* Schema.decodeEffect(TelemetryConfigJson)(yield* fs.readFileString(configPath)); + }); + +const hasNdjsonTrace = (tracesDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(tracesDir))) return false; + return (yield* fs.readDirectory(tracesDir)).some((file) => file.endsWith(".ndjson")); + }); + +const readTraceFile = (tracesDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const traceFile = (yield* fs.readDirectory(tracesDir)).find((file) => file.endsWith(".ndjson")); + expect(traceFile).toBeDefined(); + return yield* fs.readFileString(path.join(tracesDir, traceFile!)); + }); function buildLayer(opts: { home: string; @@ -66,23 +87,26 @@ function buildLayer(opts: { const value = opts.env?.[key]; return value === undefined ? Option.none() : Option.some(value); }; - const settingsLayer = Layer.succeed( + const settingsLayer = Layer.effect( CliSettings, - CliSettings.of({ - apiUrl: "https://api.supabase.com", - dashboardUrl: "https://supabase.com/dashboard", - projectHost: "supabase.co", - telemetryPosthogHost: "https://eu.i.posthog.com", - telemetryPosthogKey: Option.none(), - accessToken: Option.none(), - noKeyring: Option.none(), - supabaseHome: path.join(opts.home, ".supabase"), - debug: setting("SUPABASE_DEBUG"), - telemetryDebug: setting("SUPABASE_TELEMETRY_DEBUG"), - telemetryDisabled: setting("SUPABASE_TELEMETRY_DISABLED"), - doNotTrack: Option.none(), + Effect.gen(function* () { + const path = yield* Path.Path; + return CliSettings.of({ + apiUrl: "https://api.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + projectHost: "supabase.co", + telemetryPosthogHost: "https://eu.i.posthog.com", + telemetryPosthogKey: Option.none(), + accessToken: Option.none(), + noKeyring: Option.none(), + supabaseHome: path.join(opts.home, ".supabase"), + debug: setting("SUPABASE_DEBUG"), + telemetryDebug: setting("SUPABASE_TELEMETRY_DEBUG"), + telemetryDisabled: setting("SUPABASE_TELEMETRY_DISABLED"), + doNotTrack: Option.none(), + }); }), - ); + ).pipe(Layer.provide(fsLayer)); return Layer.mergeAll( fsLayer, opts.fileSystem ?? Layer.empty, @@ -152,122 +176,110 @@ function capturingStdio(chunks: Array): Layer.Layer { }); } -function makeSpanOptions( +const makeSpanOptions = ( overrides: Partial<{ name: string; sampled: boolean; parent: Option.Option; }> = {}, -) { - return { +) => + Effect.map(Clock.currentTimeMillis, (now) => ({ name: overrides.name ?? "test-span", parent: overrides.parent ?? Option.none(), annotations: Context.empty(), links: [] as Tracer.SpanLink[], - startTime: BigInt(Date.now()) * 1_000_000n, + startTime: BigInt(now) * 1_000_000n, kind: "internal" as Tracer.SpanKind, root: false, sampled: overrides.sampled ?? true, - }; -} + })); describe("tracingLayer – layer construction & first-run", () => { it.live("first-run TTY: creates telemetry.json with consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); + const home = tempRoot.current; return Effect.gen(function* () { - yield* Effect.void; - }).pipe( - Effect.provide(buildTracingLayer({ home, stdoutIsTty: true })), - Effect.ensuring( - Effect.sync(() => { - const configPath = path.join(configDir, "telemetry.json"); - expect(existsSync(configPath)).toBe(true); - const config: TelemetryConfig = JSON.parse(readFileSync(configPath, "utf8")); - expect(config.consent).toBe("granted"); - expect(typeof config.device_id).toBe("string"); - expect(config.device_id.length).toBeGreaterThan(0); - expect(typeof config.session_id).toBe("string"); - expect(config.session_id.length).toBeGreaterThan(0); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + yield* Effect.void.pipe(Effect.provide(buildTracingLayer({ home, stdoutIsTty: true }))); + + const configPath = path.join(configDir, "telemetry.json"); + expect(yield* fs.exists(configPath)).toBe(true); + const config = yield* readConfig(configPath); + expect(config.consent).toBe("granted"); + expect(typeof config.device_id).toBe("string"); + expect(config.device_id.length).toBeGreaterThan(0); + expect(typeof config.session_id).toBe("string"); + expect(config.session_id.length).toBeGreaterThan(0); + }).pipe(Effect.provide(fsLayer)); }); it.live("first-run non-TTY: creates telemetry.json with consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); + const home = tempRoot.current; return Effect.gen(function* () { - yield* Effect.void; - }).pipe( - Effect.provide(buildTracingLayer({ home, stdoutIsTty: false })), - Effect.ensuring( - Effect.sync(() => { - const configPath = path.join(configDir, "telemetry.json"); - expect(existsSync(configPath)).toBe(true); - const config: TelemetryConfig = JSON.parse(readFileSync(configPath, "utf8")); - expect(config.consent).toBe("granted"); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + yield* Effect.void.pipe(Effect.provide(buildTracingLayer({ home, stdoutIsTty: false }))); + + const configPath = path.join(configDir, "telemetry.json"); + expect(yield* fs.exists(configPath)).toBe(true); + const config = yield* readConfig(configPath); + expect(config.consent).toBe("granted"); + }).pipe(Effect.provide(fsLayer)); }); it.live("existing config with consent=granted: layer builds and tracer is usable", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - writeConfig(configDir, { - consent: "granted", - device_id: "existing-device", - session_id: "existing-session", - session_last_active: Date.now(), - }); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - expect(span).toBeDefined(); - expect(span.name).toBe("test-span"); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); - }); - - it.live( - "SUPABASE_TELEMETRY_DISABLED=1 overrides consent=granted: no NDJSON export on span end", - () => { - const home = makeTempDir(); + const path = yield* Path.Path; const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); - writeConfig(configDir, { + yield* writeConfig(configDir, { consent: "granted", device_id: "existing-device", session_id: "existing-session", - session_last_active: Date.now(), + session_last_active: yield* Clock.currentTimeMillis, }); - return Effect.gen(function* () { + yield* Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const span = tracer.span(yield* makeSpanOptions()); + expect(span).toBeDefined(); + expect(span.name).toBe("test-span"); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + }).pipe(Effect.provide(fsLayer)); + }); + + it.live( + "SUPABASE_TELEMETRY_DISABLED=1 overrides consent=granted: no NDJSON export on span end", + () => { + const home = tempRoot.current; + return Effect.gen(function* () { + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* writeConfig(configDir, { + consent: "granted", + device_id: "existing-device", + session_id: "existing-session", + session_last_active: yield* Clock.currentTimeMillis, + }); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), + ); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(false); + }).pipe(Effect.provide(fsLayer)); }, ); }); describe("tracingLayer – span behaviour", () => { it.live("waits for a blocked exporter before closing its scope", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make(); const release = yield* Deferred.make(); @@ -275,7 +287,7 @@ describe("tracingLayer – span behaviour", () => { const run = Effect.scoped( Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -301,18 +313,18 @@ describe("tracingLayer – span behaviour", () => { (entry === "finished" && order[index - 1] === "started"), ), ).toBe(true); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.live("drains a blocked exporter when the scoped program fails", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make(); const release = yield* Deferred.make(); const order: Array = []; const run = Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); return yield* Effect.fail("injected program failure"); }).pipe( Effect.provide( @@ -330,11 +342,11 @@ describe("tracingLayer – span behaviour", () => { const exit = yield* Fiber.await(fiber); expect(Exit.isFailure(exit)).toBe(true); expect(order.at(-1)).toBe("finished"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.live("drains a blocked exporter when the scoped program is interrupted", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make(); const release = yield* Deferred.make(); @@ -344,8 +356,8 @@ describe("tracingLayer – span behaviour", () => { const run = Effect.scoped( Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); - yield* Effect.never.pipe( + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); + return yield* Effect.never.pipe( Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), ); }).pipe( @@ -370,11 +382,11 @@ describe("tracingLayer – span behaviour", () => { const exit = yield* Fiber.await(fiber); expect(Exit.isFailure(exit)).toBe(true); expect(order.at(-1)).toBe("finished"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.effect("bounds shutdown when an exporter never completes", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make(); const release = yield* Deferred.make(); @@ -382,7 +394,7 @@ describe("tracingLayer – span behaviour", () => { const run = Effect.scoped( Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -401,45 +413,43 @@ describe("tracingLayer – span behaviour", () => { expect(Exit.isSuccess(exit)).toBe(true); expect(order).toEqual(["started"]); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.live("keeps exporting after debug formatting fails", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); + const home = tempRoot.current; const stderrChunks: string[] = []; const cyclic: Record = {}; cyclic.self = cyclic; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const badSpan = tracer.span(makeSpanOptions({ name: "bad-debug-span" })); - badSpan.attribute("cyclic", cyclic); - badSpan.end(1_000_000_000n, Exit.void); - tracer.span(makeSpanOptions({ name: "good-debug-span" })).end(1_000_000_000n, Exit.void); - }).pipe( - Effect.provide( - buildTracingLayer({ - home, - env: { SUPABASE_DEBUG: "1" }, - stdio: capturingStdio(stderrChunks), - }), - ), - Effect.ensuring( - Effect.sync(() => { - const traceFile = readdirSync(tracesDir).find((file) => file.endsWith(".ndjson")); - expect(traceFile).toBeDefined(); - const traces = readFileSync(path.join(tracesDir, traceFile!), "utf8"); - expect(traces).toContain("good-debug-span"); - expect(stderrChunks.join(" ")).toContain("good-debug-span"); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const tracesDir = path.join(home, ".supabase", "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const badSpan = tracer.span(yield* makeSpanOptions({ name: "bad-debug-span" })); + badSpan.attribute("cyclic", cyclic); + badSpan.end(1_000_000_000n, Exit.void); + tracer + .span(yield* makeSpanOptions({ name: "good-debug-span" })) + .end(1_000_000_000n, Exit.void); + }).pipe( + Effect.provide( + buildTracingLayer({ + home, + env: { SUPABASE_DEBUG: "1" }, + stdio: capturingStdio(stderrChunks), + }), + ), + ); + + const traces = yield* readTraceFile(tracesDir); + expect(traces).toContain("good-debug-span"); + expect(stderrChunks.join(" ")).toContain("good-debug-span"); + }).pipe(Effect.provide(fsLayer)); }); it.live("continues file export when debug output fails", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); + const home = tempRoot.current; const failure = PlatformError.systemError({ _tag: "Unknown", module: "stderr", @@ -448,30 +458,32 @@ describe("tracingLayer – span behaviour", () => { pathOrDescriptor: "stderr", }); return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(BigInt(Date.now()) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide( - buildTracingLayer({ - home, - env: { SUPABASE_TELEMETRY_DEBUG: "1" }, - stdio: Stdio.layerTest({ stderr: () => Sink.fail(failure) }), - }), - ), - Effect.ensuring( - Effect.sync(() => { - expect(readdirSync(tracesDir).some((file) => file.endsWith(".ndjson"))).toBe(true); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const tracesDir = path.join(home, ".supabase", "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + tracer + .span(yield* makeSpanOptions()) + .end(BigInt(yield* Clock.currentTimeMillis) * 1_000_000n, Exit.void); + }).pipe( + Effect.provide( + buildTracingLayer({ + home, + env: { SUPABASE_TELEMETRY_DEBUG: "1" }, + stdio: Stdio.layerTest({ stderr: () => Sink.fail(failure) }), + }), + ), + ); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(true); + }).pipe(Effect.provide(fsLayer)); }); it.live("span creation attaches global attributes", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); + const span = tracer.span(yield* makeSpanOptions()); expect(span.attributes.get("schema_version")).toBe(1); expect(typeof span.attributes.get("device_id")).toBe("string"); expect(typeof span.attributes.get("session_id")).toBe("string"); @@ -481,18 +493,15 @@ describe("tracingLayer – span behaviour", () => { expect(span.attributes.get("os")).toBe("linux"); expect(span.attributes.get("arch")).toBe("x64"); expect(span.attributes.get("cli_version")).toBe("0.0.0-dev"); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); it.live("span end exports to debug console when SUPABASE_DEBUG=1", () => { - const home = makeTempDir(); + const home = tempRoot.current; const stderrChunks: string[] = []; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions({ name: "debug-span" })).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions({ name: "debug-span" })).end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -504,18 +513,19 @@ describe("tracingLayer – span behaviour", () => { Effect.ensuring( Effect.sync(() => { expect(stderrChunks.join(" ")).toContain("debug-span"); - rmSync(home, { recursive: true, force: true }); }), ), ); }); it.live("span end exports to debug console when SUPABASE_TELEMETRY_DEBUG=1", () => { - const home = makeTempDir(); + const home = tempRoot.current; const stderrChunks: string[] = []; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions({ name: "telemetry-debug-span" })).end(1_000_000_000n, Exit.void); + tracer + .span(yield* makeSpanOptions({ name: "telemetry-debug-span" })) + .end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -527,154 +537,122 @@ describe("tracingLayer – span behaviour", () => { Effect.ensuring( Effect.sync(() => { expect(stderrChunks.join(" ")).toContain("telemetry-debug-span"); - rmSync(home, { recursive: true, force: true }); }), ), ); }); it.live("span end exports to NDJSON file when consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(true); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(true); + }).pipe(Effect.provide(fsLayer)); }); it.live("does not write API keys to trace files", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); + const home = tempRoot.current; const secretKey = `sb_secret_${"a".repeat(40)}`; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.attribute("http.request.header.apikey", secretKey); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - try { - const traceFile = readdirSync(tracesDir).find((file) => file.endsWith(".ndjson")); - expect(traceFile).toBeDefined(); - const trace = readFileSync(path.join(tracesDir, traceFile!), "utf8"); - expect(trace).not.toContain(secretKey); - expect(trace).not.toContain("http.request.header.apikey"); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }), - ), - ); + const path = yield* Path.Path; + const tracesDir = path.join(home, ".supabase", "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.attribute("http.request.header.apikey", secretKey); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + + const trace = yield* readTraceFile(tracesDir); + expect(trace).not.toContain(secretKey); + expect(trace).not.toContain("http.request.header.apikey"); + }).pipe(Effect.provide(fsLayer)); }); it.live("span end does NOT export to NDJSON when SUPABASE_TELEMETRY_DISABLED=1", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), + ); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(false); + }).pipe(Effect.provide(fsLayer)); }); it.live("span end skips unsampled spans – no NDJSON export", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions({ sampled: false })); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions({ sampled: false })); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(false); + }).pipe(Effect.provide(fsLayer)); }); it.live("CI detection via CI env var sets is_ci=true on span", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); + const span = tracer.span(yield* makeSpanOptions()); expect(span.attributes.get("is_ci")).toBe(true); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { CI: "true" } })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home, env: { CI: "true" } }))); }); }); describe("ExportableSpan unit tests", () => { it.live("child span inherits traceId from parent span", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const parent = tracer.span(makeSpanOptions({ name: "parent" })); - const child = tracer.span(makeSpanOptions({ name: "child", parent: Option.some(parent) })); + const parent = tracer.span(yield* makeSpanOptions({ name: "parent" })); + const child = tracer.span( + yield* makeSpanOptions({ name: "child", parent: Option.some(parent) }), + ); expect(child.traceId).toBe(parent.traceId); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); it.live("event() and addLinks() are no-ops that do not throw", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.event("test-event", BigInt(Date.now()) * 1_000_000n, { key: "val" }); + const span = tracer.span(yield* makeSpanOptions()); + span.event("test-event", BigInt(yield* Clock.currentTimeMillis) * 1_000_000n, { key: "val" }); span.addLinks([]); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); it.live("span without parent generates 32-char hex traceId and 16-char hex spanId", () => { - const home = makeTempDir(); + const home = tempRoot.current; const HEX_32 = /^[0-9a-f]{32}$/; const HEX_16 = /^[0-9a-f]{16}$/; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); + const span = tracer.span(yield* makeSpanOptions()); expect(span.traceId).toMatch(HEX_32); expect(span.spanId).toMatch(HEX_16); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); }); From 7ececb6f366301c9e2487679ce993f52226545bb Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:24:43 +0000 Subject: [PATCH 35/71] refactor(cli): cover `shared/config` and `shared/auth` with effect lint (CLI-2520) (#6876) ## TL;DR brings the `config` and `auth` areas under the effect lint ## whats introduced? effect lint applied to the config and auth layers and their tests: - `shared/config/**` and `shared/auth/**` allow list entries - `supabase-home` joins `/.supabase` through the effect `Path` service instead of `node:path`, so `SUPABASE_HOME` resolves exactly as before on posix and windows - `cliSettingsLayer` now needs `Path`, which `run.ts` and the test mocks provide through `BunServices` - `crypto.layer` reads the token name timestamp through `Clock` instead of `Date.now()`, same `cli_@_` shape - `platform-api.layer` yields `PlatformAuthRequiredError` directly instead of wrapping it in `Effect.fail`, same message and suggestion - tests use scoped temp dirs and `FileSystem` instead of `node:fs`, `node:path` and `node:os`, and the token timestamp test stays on the real clock ## ref: - closes: CLI-2520 --- .oxlintrc.effect.json | 2 + .../cli/src/command-internal/pgdelta.paths.ts | 2 +- .../telemetry/telemetry.integration.test.ts | 1 + apps/cli/src/config/command-settings.layer.ts | 6 +- apps/cli/src/config/profile-file.ts | 5 +- .../auth/credentials.layer.unit.test.ts | 186 +++++----- apps/cli/src/shared/auth/crypto.layer.ts | 25 +- .../src/shared/auth/crypto.layer.unit.test.ts | 57 ++- .../cli/src/shared/auth/platform-api.layer.ts | 12 +- .../auth/platform-api.layer.unit.test.ts | 26 +- apps/cli/src/shared/auth/token.unit.test.ts | 12 +- apps/cli/src/shared/cli/run.ts | 1 + .../cli-project-context.layer.unit.test.ts | 91 +++-- .../cli-project-home.layer.unit.test.ts | 227 ++++++------ ...-local-service-versions.layer.unit.test.ts | 171 +++++---- .../src/shared/config/cli-settings.layer.ts | 5 +- .../config/cli-settings.layer.unit.test.ts | 325 +++++++++--------- .../project-link-state.layer.unit.test.ts | 312 ++++++++--------- apps/cli/src/shared/config/supabase-home.ts | 13 +- .../shared/config/supabase-home.unit.test.ts | 71 ++-- .../src/shared/telemetry/consent.unit.test.ts | 1 + .../telemetry/runtime.layer.unit.test.ts | 1 + .../src/telemetry/telemetry-state.layer.ts | 2 +- apps/cli/tests/helpers/mocks.ts | 1 + 24 files changed, 794 insertions(+), 761 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 1c421644d2..f3bae1efe0 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -8,7 +8,9 @@ "**", "!packages/stack/**", "!apps/cli/src/commands/**", + "!apps/cli/src/shared/auth/**", "!apps/cli/src/shared/compute/**", + "!apps/cli/src/shared/config/**", "!apps/cli/src/shared/functions/functions-docker.ts", "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", "!apps/cli/src/shared/functions/serve.ts", diff --git a/apps/cli/src/command-internal/pgdelta.paths.ts b/apps/cli/src/command-internal/pgdelta.paths.ts index f63d028826..f4f10f139c 100644 --- a/apps/cli/src/command-internal/pgdelta.paths.ts +++ b/apps/cli/src/command-internal/pgdelta.paths.ts @@ -31,5 +31,5 @@ export function shadowBaselineCacheDir( env: Readonly> = process.env, homeDir: string = homedir(), ): string { - return path.join(resolveSupabaseHome(env, homeDir), "cache", "shadow-baseline"); + return path.join(resolveSupabaseHome(path, env, homeDir), "cache", "shadow-baseline"); } diff --git a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts index d30c8f5f3d..96d5fdd7ee 100644 --- a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts +++ b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts @@ -75,6 +75,7 @@ function setupWithRealAnalytics(dir: string) { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ); const analytics = analyticsLayer.pipe( Layer.provide(configLayer), diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index 8c0be720c1..9cfecd1851 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -116,7 +116,11 @@ export const commandSettingsLayer = Layer.unwrap( const read =
(config: Config.Config) => config.parse(provider); const profileEnvValue = yield* read(Config.option(Config.string("SUPABASE_PROFILE"))); const supabaseHome = yield* read(Config.option(Config.string("SUPABASE_HOME"))); - const resolvedSupabaseHome = resolveSupabaseHomeValue(supabaseHome, runtimeInfo.homeDir); + const resolvedSupabaseHome = resolveSupabaseHomeValue( + path, + supabaseHome, + runtimeInfo.homeDir, + ); // Optional service: tests without argv default to "not explicit". An empty command // path scans all of argv up to `--`, matching pflag. diff --git a/apps/cli/src/config/profile-file.ts b/apps/cli/src/config/profile-file.ts index aeba0faa1a..b96d90a1e0 100644 --- a/apps/cli/src/config/profile-file.ts +++ b/apps/cli/src/config/profile-file.ts @@ -21,10 +21,11 @@ import { * directly, so `env` defaults to it. */ export function supabaseHome( + path: Path.Path, homeDir: string, env: Readonly> = process.env, ): string { - return resolveSupabaseHome(env, homeDir); + return resolveSupabaseHome(path, env, homeDir); } /** Raised when persisting the profile name fails — fails `login` outright, @@ -42,7 +43,7 @@ export function profileFilePath( homeDir: string, env?: Readonly>, ): string { - return path.join(supabaseHome(homeDir, env), "profile"); + return path.join(supabaseHome(path, homeDir, env), "profile"); } /** Writes the profile name to the resolved profile path. Fatal on failure. */ diff --git a/apps/cli/src/shared/auth/credentials.layer.unit.test.ts b/apps/cli/src/shared/auth/credentials.layer.unit.test.ts index a75921c995..30e2c2928d 100644 --- a/apps/cli/src/shared/auth/credentials.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/credentials.layer.unit.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { afterEach, beforeEach, vi } from "vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; +import { beforeEach, vi } from "vitest"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Redacted, +} from "effect"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { mockCliProjectContext, mockRuntimeInfo, @@ -79,6 +86,7 @@ function makeLayer( Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ), ); return credentialsLayer.pipe( @@ -88,7 +96,16 @@ function makeLayer( ); } -let tempHome: string; +const tempHome = useTempWorkdir("supabase-creds-test-"); + +const writeFallbackToken = (content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supaDir = path.join(tempHome.current, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), content, { mode: 0o600 }); + }); beforeEach(() => { passwords.clear(); @@ -96,11 +113,6 @@ beforeEach(() => { throwOnGetPasswordAccounts.clear(); returnNullForAccounts.clear(); throwOnDeletePasswordAccounts.clear(); - tempHome = mkdtempSync(join(tmpdir(), "supabase-creds-test-")); -}); - -afterEach(() => { - rmSync(tempHome, { recursive: true, force: true }); }); describe("Credentials", () => { @@ -118,7 +130,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("decodes Go keyring base64 values from current account", () => { @@ -127,7 +139,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("falls back to legacy account when current is missing", () => { @@ -136,7 +148,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "legacy-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("prefers current account over legacy", () => { @@ -146,7 +158,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("returns none when no token found anywhere", () => { @@ -154,84 +166,79 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("falls back to filesystem when keyring throws", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token-123", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-token-123"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "fs-token-123"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("returns Some from filesystem in no-keyring mode", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-only-token", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-only-token"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "fs-only-token"); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }), + BunServices.layer, + ), + ), + ); }); it.effect("returns None when filesystem file is empty", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken(""); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("returns None when filesystem file has only whitespace", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), " \n \t ", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken(" \n \t "); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("falls through when keyring returns null for both accounts", () => { returnNullForAccounts.add("Supabase CLI/access-token"); returnNullForAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-fallback-token", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-fallback-token"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; // keyring returns null (falsy) for both → falls through to filesystem expectSomeToken(token, "fs-fallback-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("falls through when keyring returns empty passwords for both accounts", () => { passwords.set("Supabase CLI/access-token", ""); passwords.set("Supabase CLI/supabase", ""); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-empty-keyring-fallback", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-empty-keyring-fallback"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "fs-empty-keyring-fallback"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("surfaces a filesystem read failure instead of treating it as no token", () => { @@ -260,9 +267,9 @@ describe("Credentials", () => { ), }), ); - const runtimeInfoLayer = mockRuntimeInfo({ homeDir: tempHome }); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: tempHome.current }); const cliProjectContextLayer = mockCliProjectContext(); - const envLayer = processEnvLayer({ HOME: tempHome, SUPABASE_NO_KEYRING: "1" }); + const envLayer = processEnvLayer({ HOME: tempHome.current, SUPABASE_NO_KEYRING: "1" }); const layer = credentialsLayer.pipe( Layer.provide(failingFs), Layer.provide(BunServices.layer), @@ -273,6 +280,7 @@ describe("Credentials", () => { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ), ), ); @@ -314,7 +322,7 @@ describe("Credentials", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }, failingFs))); + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }, failingFs))); }); it.effect("saves to keyring when available", () => { @@ -322,36 +330,52 @@ describe("Credentials", () => { const { saveAccessToken } = yield* Credentials; yield* saveAccessToken("new-token"); expect(passwords.get("Supabase CLI/access-token")).toBe("new-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("falls back to filesystem when setPassword throws", () => { throwOnSetPassword = true; return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("fallback-token"); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("fallback-token"); + }).pipe(Effect.provide(makeLayer(tempHome.current))); + const content = yield* fs.readFileString( + path.join(tempHome.current, ".supabase", "access-token"), + ); expect(content).toBe("fallback-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("saves to filesystem in no-keyring mode", () => { return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("no-keyring-token"); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("no-keyring-token"); + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }))); + const content = yield* fs.readFileString( + path.join(tempHome.current, ".supabase", "access-token"), + ); expect(content).toBe("no-keyring-token"); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("creates .supabase directory if missing", () => { throwOnSetPassword = true; return Effect.gen(function* () { - expect(existsSync(join(tempHome, ".supabase"))).toBe(false); - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("create-dir-token"); - expect(existsSync(join(tempHome, ".supabase"))).toBe(true); - }).pipe(Effect.provide(makeLayer(tempHome))); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.exists(path.join(tempHome.current, ".supabase"))).toBe(false); + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("create-dir-token"); + }).pipe(Effect.provide(makeLayer(tempHome.current))); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase"))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)); }); }); @@ -381,7 +405,7 @@ describe("Credentials", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }, failingFs))); + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }, failingFs))); }); it.effect("returns false when no token exists anywhere", () => { @@ -389,7 +413,7 @@ describe("Credentials", () => { const { deleteAccessToken } = yield* Credentials; const deleted = yield* deleteAccessToken; expect(deleted).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes current keyring account and returns true", () => { @@ -399,7 +423,7 @@ describe("Credentials", () => { const deleted = yield* deleteAccessToken; expect(deleted).toBe(true); expect(passwords.has("Supabase CLI/access-token")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes legacy keyring account when current is absent", () => { @@ -409,7 +433,7 @@ describe("Credentials", () => { const deleted = yield* deleteAccessToken; expect(deleted).toBe(true); expect(passwords.has("Supabase CLI/supabase")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes both keyring accounts when both exist", () => { @@ -421,33 +445,41 @@ describe("Credentials", () => { expect(deleted).toBe(true); expect(passwords.has("Supabase CLI/access-token")).toBe(false); expect(passwords.has("Supabase CLI/supabase")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes filesystem token and returns true", () => { throwOnDeletePasswordAccounts.add("Supabase CLI/access-token"); throwOnDeletePasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token", { mode: 0o600 }); return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeFallbackToken("fs-token"); + const deleted = yield* Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + return yield* deleteAccessToken; + }).pipe(Effect.provide(makeLayer(tempHome.current))); expect(deleted).toBe(true); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase", "access-token"))).toBe( + false, + ); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("deletes filesystem token in no-keyring mode", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token", { mode: 0o600 }); return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeFallbackToken("fs-token"); + const deleted = yield* Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + return yield* deleteAccessToken; + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }))); expect(deleted).toBe(true); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase", "access-token"))).toBe( + false, + ); + }).pipe(Effect.provide(BunServices.layer)); }); }); }); diff --git a/apps/cli/src/shared/auth/crypto.layer.ts b/apps/cli/src/shared/auth/crypto.layer.ts index 7bf8e2db6f..32a1f40ab6 100644 --- a/apps/cli/src/shared/auth/crypto.layer.ts +++ b/apps/cli/src/shared/auth/crypto.layer.ts @@ -1,7 +1,7 @@ import { Buffer } from "node:buffer"; import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; import { hostname, userInfo } from "node:os"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; import { Crypto, type EncryptedPayload } from "./crypto.service.ts"; @@ -13,17 +13,18 @@ export const cryptoLayer = Layer.sync(Crypto, () => return { ecdh, publicKeyHex: ecdh.getPublicKey("hex", "uncompressed") }; }), generateSessionId: Effect.sync(() => randomUUID()), - defaultTokenName: Effect.sync(() => { - const ts = Date.now(); - try { - const user = userInfo().username; - const host = hostname(); - if (user && host) return `cli_${user}@${host}_${ts}`; - } catch { - /* fall through */ - } - return `cli_${ts}`; - }), + defaultTokenName: Clock.currentTimeMillis.pipe( + Effect.map((ts) => { + try { + const user = userInfo().username; + const host = hostname(); + if (user && host) return `cli_${user}@${host}_${ts}`; + } catch { + /* fall through */ + } + return `cli_${ts}`; + }), + ), decryptToken: (ecdh: ECDH, payload: EncryptedPayload) => Effect.sync(() => { const sharedSecret = ecdh.computeSecret(Buffer.from(payload.publicKey, "hex")); diff --git a/apps/cli/src/shared/auth/crypto.layer.unit.test.ts b/apps/cli/src/shared/auth/crypto.layer.unit.test.ts index 4021000bff..314b78b996 100644 --- a/apps/cli/src/shared/auth/crypto.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/crypto.layer.unit.test.ts @@ -2,7 +2,7 @@ import { Buffer } from "node:buffer"; import { describe, expect, it } from "@effect/vitest"; import { createCipheriv, createECDH, randomBytes } from "node:crypto"; import { vi } from "vitest"; -import { Cause, Effect, Exit } from "effect"; +import { Cause, Clock, Effect, Exit } from "effect"; import { Crypto } from "./crypto.service.ts"; import { cryptoLayer } from "./crypto.layer.ts"; @@ -11,17 +11,16 @@ const mockOs = vi.hoisted(() => ({ userInfoReturnEmptyUsername: false, })); -vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { +vi.mock("node:os", (importOriginal) => + importOriginal().then((actual) => ({ ...actual, userInfo: (...args: Parameters) => { if (mockOs.userInfoShouldThrow) throw new Error("userInfo unavailable"); if (mockOs.userInfoReturnEmptyUsername) return { ...actual.userInfo(...args), username: "" }; return actual.userInfo(...args); }, - }; -}); + })), +); const testLayer = cryptoLayer; @@ -110,12 +109,12 @@ describe("Crypto", () => { }).pipe(Effect.provide(testLayer)); }); - it.effect("contains a numeric timestamp", () => { - const before = Date.now(); - return Effect.gen(function* () { + it.live("contains a numeric timestamp", () => + Effect.gen(function* () { + const before = yield* Clock.currentTimeMillis; const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; - const after = Date.now(); + const after = yield* Clock.currentTimeMillis; // Token names end with _: cli_ or cli_@_. const match = name.match(/_(\d+)$/); @@ -123,8 +122,8 @@ describe("Crypto", () => { const ts = Number(match![1]); expect(ts).toBeGreaterThanOrEqual(before); expect(ts).toBeLessThanOrEqual(after); - }).pipe(Effect.provide(testLayer)); - }); + }).pipe(Effect.provide(testLayer)), + ); it.effect("falls back to cli_ when userInfo throws", () => { mockOs.userInfoShouldThrow = true; @@ -134,15 +133,14 @@ describe("Crypto", () => { const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; expect(name).toMatch(/^cli_\d+$/); - }) - .pipe(Effect.provide(testLayer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - mockOs.userInfoShouldThrow = false; - }), - ), - ); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring( + Effect.sync(() => { + mockOs.userInfoShouldThrow = false; + }), + ), + ); }); it.effect("falls back to cli_ when username is empty (if-branch false path)", () => { @@ -151,15 +149,14 @@ describe("Crypto", () => { const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; expect(name).toMatch(/^cli_\d+$/); - }) - .pipe(Effect.provide(testLayer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - mockOs.userInfoReturnEmptyUsername = false; - }), - ), - ); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring( + Effect.sync(() => { + mockOs.userInfoReturnEmptyUsername = false; + }), + ), + ); }); }); diff --git a/apps/cli/src/shared/auth/platform-api.layer.ts b/apps/cli/src/shared/auth/platform-api.layer.ts index 63b205856b..55aa052ecc 100644 --- a/apps/cli/src/shared/auth/platform-api.layer.ts +++ b/apps/cli/src/shared/auth/platform-api.layer.ts @@ -17,13 +17,11 @@ export const makePlatformApiServices = Effect.gen(function* () { const token = Option.isSome(configuredToken) ? configuredToken : storedToken; if (Option.isNone(token)) { - return yield* Effect.fail( - new PlatformAuthRequiredError({ - message: "You are not logged in to Supabase.", - detail: "Platform commands require a management API access token.", - suggestion: "Run `supabase login` or set SUPABASE_ACCESS_TOKEN before retrying.", - }), - ); + return yield* new PlatformAuthRequiredError({ + message: "You are not logged in to Supabase.", + detail: "Platform commands require a management API access token.", + suggestion: "Run `supabase login` or set SUPABASE_ACCESS_TOKEN before retrying.", + }); } const config = { diff --git a/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts b/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts index a1803a9e0c..c4a9f5d132 100644 --- a/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Redacted, Stdio } from "effect"; +import { Effect, Exit, Layer, Option, Redacted, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -132,11 +132,9 @@ describe("platformApiLayer", () => { ); return Effect.gen(function* () { - const exit = yield* Effect.gen(function* () { - return yield* PlatformApi; - }).pipe(Effect.provide(layer), Effect.exit); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { + const exit = yield* PlatformApi.pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain("PlatformAuthRequiredError"); } }); @@ -241,14 +239,16 @@ describe("platformApiLayer", () => { yield* api.v1.listAllBranches({ ref: "abcdefghijklmnopqrst" }); }).pipe( withCommandInstrumentation(), - Effect.provide(layer), - Effect.provide(runtimeLayer), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["branches", "list"]), - }), + Layer.mergeAll( + layer, + runtimeLayer, + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["branches", "list"]), + }), + ), ), Effect.tap(() => Effect.sync(() => { diff --git a/apps/cli/src/shared/auth/token.unit.test.ts b/apps/cli/src/shared/auth/token.unit.test.ts index 86400c21a8..b420e388df 100644 --- a/apps/cli/src/shared/auth/token.unit.test.ts +++ b/apps/cli/src/shared/auth/token.unit.test.ts @@ -17,21 +17,15 @@ function expectInvalidTokenError(exit: Exit.Exit) { describe("validateToken", () => { describe("valid tokens", () => { it.live("accepts sbp_ prefix with 40 lowercase hex chars", () => - Effect.gen(function* () { - yield* validateToken(`sbp_${VALID_HEX_40}`); - }), + validateToken(`sbp_${VALID_HEX_40}`), ); it.live("accepts sbp_oauth_ prefix with 40 lowercase hex chars", () => - Effect.gen(function* () { - yield* validateToken(`sbp_oauth_${VALID_HEX_40}`); - }), + validateToken(`sbp_oauth_${VALID_HEX_40}`), ); it.live("accepts all valid hex characters (a-f, 0-9)", () => - Effect.gen(function* () { - yield* validateToken("sbp_abcdef0123456789abcdef0123456789abcdef01"); - }), + validateToken("sbp_abcdef0123456789abcdef0123456789abcdef01"), ); }); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 1e54c8e767..f5766aaf30 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -522,6 +522,7 @@ function cliSettingsLayerFor(runtimeLayer: Layer.Layer) { return cliSettingsLayer.pipe( Layer.provide(cliProjectContextLayerFor(runtimeLayer)), Layer.provide(runtimeLayer), + Layer.provide(BunServices.layer), ); } diff --git a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts index c853d54073..f10c95ee46 100644 --- a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts @@ -1,22 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-context-")); -} +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-context-" }), +); -function buildLayer(opts: { cwd: string; env?: Record }) { +function buildLayer(path: Path.Path, opts: { cwd: string; env?: Record }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: join(opts.cwd, ".home"), + homeDir: path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); return cliProjectContextLayer.pipe( @@ -27,36 +23,34 @@ function buildLayer(opts: { cwd: string; env?: Record }) { } describe("cliProjectContextLayer", () => { - it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(projectRoot, "supabase", "config.toml"), - [ - 'project_id = "with-env-ports"', - "", - "[api]", - 'port = "env(SUPABASE_API_PORT)"', - "", - "[db]", - 'port = "env(SUPABASE_DB_PORT)"', - "", - "[analytics]", - 'port = "env(SUPABASE_ANALYTICS_PORT)"', - "", - ].join("\n"), - ), + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + [ + 'project_id = "with-env-ports"', + "", + "[api]", + 'port = "env(SUPABASE_API_PORT)"', + "", + "[db]", + 'port = "env(SUPABASE_DB_PORT)"', + "", + "[analytics]", + 'port = "env(SUPABASE_ANALYTICS_PORT)"', + "", + ].join("\n"), ); - const cliProjectContext = yield* Effect.gen(function* () { - return yield* CliProjectContext; - }).pipe( + const cliProjectContext = yield* CliProjectContext.pipe( Effect.provide( - buildLayer({ + buildLayer(path, { cwd: projectRoot, env: { SUPABASE_API_PORT: "54321", @@ -72,23 +66,20 @@ describe("cliProjectContextLayer", () => { expect(cliProjectContext.paths.value.projectRoot).toBe(projectRoot); } expect(Option.isSome(cliProjectContext.projectEnv)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("returns empty context when no supabase project is found", () => { - const tempDir = makeTempDir(); + it.live("returns empty context when no supabase project is found", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; - return Effect.gen(function* () { - const cliProjectContext = yield* Effect.gen(function* () { - return yield* CliProjectContext; - }).pipe(Effect.provide(buildLayer({ cwd: tempDir }))); + const cliProjectContext = yield* CliProjectContext.pipe( + Effect.provide(buildLayer(path, { cwd: tempDir })), + ); expect(Option.isNone(cliProjectContext.paths)).toBe(true); expect(Option.isNone(cliProjectContext.projectEnv)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts index a3862c51af..e2843f0400 100644 --- a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; @@ -12,14 +8,17 @@ import { cliProjectHomeLayer } from "./cli-project-home.layer.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; import { CliProjectHome, CliProjectHomeNotDirectoryError } from "./cli-project-home.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-home-")); -} +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-home-" }), +); -function buildLayer(opts: { cwd: string; env?: Record; homeDir?: string }) { +function buildLayer( + path: Path.Path, + opts: { cwd: string; env?: Record; homeDir?: string }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -28,6 +27,7 @@ function buildLayer(opts: { cwd: string; env?: Record; homeDir?: Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), ); @@ -49,18 +49,21 @@ function buildLayer(opts: { cwd: string; env?: Record; homeDir?: } describe("cliProjectHomeLayer", () => { - it.live("resolves a repo-local project home from the nearest discovered config root", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const cwd = join(packageRoot, "src"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(packageRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'), + it.live("resolves a repo-local project home from the nearest discovered config root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const repoRoot = path.join(tempDir, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(packageRoot, "src"); + const supabaseHome = path.join(tempDir, "supabase-home"); + + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', ); const { cliProjectHome, cliProjectContext } = yield* Effect.gen(function* () { @@ -68,110 +71,102 @@ describe("cliProjectHomeLayer", () => { cliProjectHome: yield* CliProjectHome, cliProjectContext: yield* CliProjectContext, }; - }).pipe(Effect.provide(buildLayer({ cwd, env: { SUPABASE_HOME: supabaseHome } }))); + }).pipe(Effect.provide(buildLayer(path, { cwd, env: { SUPABASE_HOME: supabaseHome } }))); expect(Option.isSome(cliProjectContext.paths)).toBe(true); expect(cliProjectHome.projectRoot).toBe(packageRoot); - expect(cliProjectHome.supabaseDir).toBe(join(packageRoot, "supabase")); - expect(cliProjectHome.projectHomeDir).toBe(join(packageRoot, ".supabase")); + expect(cliProjectHome.supabaseDir).toBe(path.join(packageRoot, "supabase")); + expect(cliProjectHome.projectHomeDir).toBe(path.join(packageRoot, ".supabase")); expect(cliProjectHome.projectLocalVersionsPath).toBe( - join(packageRoot, ".supabase", "local-versions.json"), + path.join(packageRoot, ".supabase", "local-versions.json"), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("falls back to the nearest linked project root when no project config exists", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const projectRoot = join(repoRoot, "apps", "web"); - const cwd = join(projectRoot, "src", "feature"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, ".supabase", "project.json"), "{}\n"), - ); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - const layer = buildLayer({ cwd, env: { SUPABASE_HOME: join(tempDir, "supabase-home") } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + it.live("falls back to the nearest linked project root when no project config exists", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const repoRoot = path.join(tempDir, "repo"); + const projectRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(projectRoot, "src", "feature"); + + yield* fs.makeDirectory(path.join(projectRoot, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, ".supabase", "project.json"), "{}\n"); + yield* fs.makeDirectory(cwd, { recursive: true }); + + const layer = buildLayer(path, { + cwd, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); expect(cliProjectHome.projectRoot).toBe(projectRoot); - expect(cliProjectHome.projectHomeDir).toBe(join(projectRoot, ".supabase")); - expect(cliProjectHome.supabaseDir).toBe(join(projectRoot, "supabase")); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + expect(cliProjectHome.projectHomeDir).toBe(path.join(projectRoot, ".supabase")); + expect(cliProjectHome.supabaseDir).toBe(path.join(projectRoot, "supabase")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("does not let a bare ancestor .supabase directory capture a nested checkout", () => { - const tempDir = makeTempDir(); - const parentRoot = join(tempDir, "workspace"); - const cwd = join(parentRoot, "test-cli-v3"); + it.live("does not let a bare ancestor .supabase directory capture a nested checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const parentRoot = path.join(tempDir, "workspace"); + const cwd = path.join(parentRoot, "test-cli-v3"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(parentRoot, ".supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); + yield* fs.makeDirectory(path.join(parentRoot, ".supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); - const layer = buildLayer({ cwd, env: { SUPABASE_HOME: join(tempDir, "supabase-home") } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { + cwd, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); expect(cliProjectHome.projectRoot).toBe(cwd); - expect(cliProjectHome.projectHomeDir).toBe(join(cwd, ".supabase")); - expect(cliProjectHome.projectLinkPath).toBe(join(cwd, ".supabase", "project.json")); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + expect(cliProjectHome.projectHomeDir).toBe(path.join(cwd, ".supabase")); + expect(cliProjectHome.projectLinkPath).toBe(path.join(cwd, ".supabase", "project.json")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("creates the repo-local .supabase directory lazily", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + it.live("creates the repo-local .supabase directory lazily", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - const layer = buildLayer({ + const layer = buildLayer(path, { cwd: projectRoot, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); yield* cliProjectHome.ensureCliProjectHomeDir; - yield* Effect.tryPromise(() => writeFile(cliProjectHome.projectLinkPath, "{}\n")); - expect(yield* Effect.tryPromise(() => readFile(cliProjectHome.projectLinkPath, "utf8"))).toBe( - "{}\n", - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + yield* fs.writeFileString(cliProjectHome.projectLinkPath, "{}\n"); + expect(yield* fs.readFileString(cliProjectHome.projectLinkPath)).toBe("{}\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "fails with CliProjectHomeNotDirectoryError when a FILE occupies the .supabase path", - () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(projectRoot, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, ".supabase"), "not a directory\n"), - ); + yield* fs.makeDirectory(projectRoot, { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, ".supabase"), "not a directory\n"); - const layer = buildLayer({ + const layer = buildLayer(path, { cwd: projectRoot, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); const exit = yield* cliProjectHome.ensureCliProjectHomeDir.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -184,32 +179,29 @@ describe("cliProjectHomeLayer", () => { expect(error.value.message).toContain("could not be created"); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); it.live( "fails with CliProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", - () => { + () => // Distinct from the AlreadyExists case above: here `.supabase` doesn't exist, but a file // sits on one of its own parent directories, so `mkdir` fails with ENOTDIR while // traversing, not EEXIST on the leaf itself. - const tempDir = makeTempDir(); - const fileAsDir = join(tempDir, "proj"); - const cwd = join(fileAsDir, "child"); + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const fileAsDir = path.join(tempDir, "proj"); + const cwd = path.join(fileAsDir, "child"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeFile(fileAsDir, "not a directory\n")); + yield* fs.writeFileString(fileAsDir, "not a directory\n"); - const layer = buildLayer({ + const layer = buildLayer(path, { cwd, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); const exit = yield* cliProjectHome.ensureCliProjectHomeDir.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -222,9 +214,6 @@ describe("cliProjectHomeLayer", () => { expect(error.value.message).toContain("could not be created"); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts index 007ab35322..4f422e9147 100644 --- a/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, PlatformError } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Schema, +} from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; @@ -13,19 +19,24 @@ import { cliProjectLocalServiceVersionsLayer } from "./cli-project-local-service import { CliProjectHome } from "./cli-project-home.service.ts"; import { CliProjectLocalServiceVersions } from "./cli-project-local-service-versions.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-local-versions-")); -} - -function buildLayer(opts: { - cwd: string; - env?: Record; - homeDir?: string; - fs?: Layer.Layer; -}) { +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-local-versions-" }), +); + +const PrettyJsonString = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + +function buildLayer( + path: Path.Path, + opts: { + cwd: string; + env?: Record; + homeDir?: string; + fs?: Layer.Layer; + }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -34,6 +45,7 @@ function buildLayer(opts: { Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), ); @@ -61,8 +73,6 @@ function buildLayer(opts: { describe("cliProjectLocalServiceVersionsLayer", () => { it.live("surfaces a filesystem read permission failure", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); const fsLayer = Layer.succeed( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -80,10 +90,14 @@ describe("cliProjectLocalServiceVersionsLayer", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); - - const layer = buildLayer({ cwd: projectRoot, fs: fsLayer }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + + const layer = buildLayer(path, { cwd: projectRoot, fs: fsLayer }); const localVersions = yield* CliProjectLocalServiceVersions.pipe(Effect.provide(layer)); const exit = yield* Effect.exit(localVersions.load); @@ -95,20 +109,19 @@ describe("cliProjectLocalServiceVersionsLayer", () => { expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); - it.live("fails with a tagged error when local service versions are malformed", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + it.live("fails with a tagged error when local service versions are malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); - const layer = buildLayer({ cwd: projectRoot }); + const layer = buildLayer(path, { cwd: projectRoot }); const { cliProjectHome, localVersions } = yield* Effect.gen(function* () { return { cliProjectHome: yield* CliProjectHome, @@ -117,9 +130,7 @@ describe("cliProjectLocalServiceVersionsLayer", () => { }).pipe(Effect.provide(layer)); yield* cliProjectHome.ensureCliProjectHomeDir; - yield* Effect.tryPromise(() => - writeFile(cliProjectHome.projectLocalVersionsPath, "{not-json"), - ); + yield* fs.writeFileString(cliProjectHome.projectLocalVersionsPath, "{not-json"); const exit = yield* Effect.exit(localVersions.load); expect(Exit.isFailure(exit)).toBe(true); @@ -130,21 +141,20 @@ describe("cliProjectLocalServiceVersionsLayer", () => { expect(error.value).toMatchObject({ _tag: "InvalidLocalServiceVersionsStateError" }); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("loads local service version overrides from repo-local state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + it.live("loads local service version overrides from repo-local state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); const { cliProjectHome, localVersions } = yield* Effect.gen(function* () { return { cliProjectHome: yield* CliProjectHome, @@ -153,21 +163,15 @@ describe("cliProjectLocalServiceVersionsLayer", () => { }).pipe(Effect.provide(layer)); yield* cliProjectHome.ensureCliProjectHomeDir; - yield* Effect.tryPromise(() => - writeFile( - cliProjectHome.projectLocalVersionsPath, - JSON.stringify( - { - updatedAt: "2026-03-21T12:00:00.000Z", - versions: { - auth: "v2.180.0", - storage: "1.40.0", - }, - }, - null, - 2, - ), - ), + yield* fs.writeFileString( + cliProjectHome.projectLocalVersionsPath, + yield* Schema.encodeEffect(PrettyJsonString)({ + updatedAt: "2026-03-21T12:00:00.000Z", + versions: { + auth: "v2.180.0", + storage: "1.40.0", + }, + }), ); const loaded = yield* localVersions.load; @@ -178,29 +182,24 @@ describe("cliProjectLocalServiceVersionsLayer", () => { storage: "1.40.0", }); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("returns none when no local override file exists", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const localVersions = yield* Effect.gen(function* () { - return yield* CliProjectLocalServiceVersions; - }).pipe(Effect.provide(layer)); + it.live("returns none when no local override file exists", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const localVersions = yield* CliProjectLocalServiceVersions.pipe(Effect.provide(layer)); const loaded = yield* localVersions.load; expect(Option.isNone(loaded)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/cli-settings.layer.ts b/apps/cli/src/shared/config/cli-settings.layer.ts index 22110d5c63..f5316c4896 100644 --- a/apps/cli/src/shared/config/cli-settings.layer.ts +++ b/apps/cli/src/shared/config/cli-settings.layer.ts @@ -1,4 +1,4 @@ -import { Config, ConfigProvider, Effect, Layer, Option, Redacted } from "effect"; +import { Config, ConfigProvider, Effect, Layer, Option, Path, Redacted } from "effect"; import { resolveSupabaseHomeValue } from "./supabase-home.ts"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { resolvePosthogConfig } from "../telemetry/posthog-config.ts"; @@ -10,6 +10,7 @@ const SUPABASE_DASHBOARD_URL = "https://supabase.com/dashboard"; const SUPABASE_PROJECT_HOST = "supabase.co"; const makeCliSettings = Effect.gen(function* () { + const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliProjectContext = yield* CliProjectContext; const ambientProvider = yield* ConfigProvider.ConfigProvider; @@ -41,7 +42,7 @@ const makeCliSettings = Effect.gen(function* () { (token) => Redacted.make(token, { label: "SUPABASE_ACCESS_TOKEN" }), ), noKeyring: yield* read(Config.option(Config.string("SUPABASE_NO_KEYRING"))), - supabaseHome: resolveSupabaseHomeValue(supabaseHome, runtimeInfo.homeDir), + supabaseHome: resolveSupabaseHomeValue(path, supabaseHome, runtimeInfo.homeDir), debug: yield* read(Config.option(Config.string("SUPABASE_DEBUG"))), telemetryDebug: yield* read(Config.option(Config.string("SUPABASE_TELEMETRY_DEBUG"))), telemetryDisabled: yield* read(Config.option(Config.string("SUPABASE_TELEMETRY_DISABLED"))), diff --git a/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts b/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts index e57e201c20..62e409f354 100644 --- a/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { ConfigProvider, Effect, Layer, Option, Redacted } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { mockCliProjectContext, mockRuntimeInfo, @@ -16,19 +12,22 @@ import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-cli-settings-")); -} +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-cli-settings-" }), +); -function buildLayer(opts: { - cwd: string; - env?: Record; - providerEnv?: Record; - homeDir?: string; -}) { +function buildLayer( + path: Path.Path, + opts: { + cwd: string; + env?: Record; + providerEnv?: Record; + homeDir?: string; + }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -37,6 +36,7 @@ function buildLayer(opts: { Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), Layer.provide( @@ -59,82 +59,86 @@ function buildLayer(opts: { describe("cliSettingsLayer", () => { for (const optOut of ["SUPABASE_TELEMETRY_DISABLED", "DO_NOT_TRACK"]) { - it.live(`honors injected ${optOut} alongside discovered project settings`, () => { - const cwd = makeTempDir(); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"))); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "demo"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "SUPABASE_DEBUG=\n"), + it.live(`honors injected ${optOut} alongside discovered project settings`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fs.makeDirectory(path.join(cwd, "supabase")); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "demo"\n', ); + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), "SUPABASE_DEBUG=\n"); yield* Effect.gen(function* () { const settings = yield* CliSettings; expect(settings.debug).toEqual(Option.some("")); expect(yield* getEffectiveConsent(Option.none())).toBe("denied"); }).pipe( Effect.provide( - buildLayer({ cwd, providerEnv: { [optOut]: "1", SUPABASE_DEBUG: "true" } }), + buildLayer(path, { cwd, providerEnv: { [optOut]: "1", SUPABASE_DEBUG: "true" } }), ), ); - }).pipe(Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true })))); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); } - it.live("falls back to ambient env when no Supabase project is found", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; - const cliProjectContext = yield* CliProjectContext; + it.live("falls back to ambient env when no Supabase project is found", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + yield* Effect.gen(function* () { + const cliSettings = yield* CliSettings; + const cliProjectContext = yield* CliProjectContext; - expect(cliSettings.apiUrl).toBe("https://ambient.example"); - expect(Option.isNone(cliProjectContext.paths)).toBe(true); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_API_URL: "https://ambient.example", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + expect(cliSettings.apiUrl).toBe("https://ambient.example"); + expect(Option.isNone(cliProjectContext.paths)).toBe(true); + }).pipe( + Effect.provide( + buildLayer(path, { + cwd: tempDir, + env: { + SUPABASE_API_URL: "https://ambient.example", + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "uses the nearest discovered project and loads supabase/.env.local over supabase/.env", - () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const cwd = join(packageRoot, "src"); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const repoRoot = path.join(tempDir, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(packageRoot, "src"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(repoRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(packageRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + yield* fs.makeDirectory(path.join(repoRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - yield* Effect.tryPromise(() => - writeFile(join(repoRoot, "supabase", ".env"), "SUPABASE_API_URL=https://repo.example\n"), + yield* fs.writeFileString( + path.join(repoRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://repo.example\n", ); - yield* Effect.tryPromise(() => - writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(packageRoot, "supabase", ".env"), - "SUPABASE_API_URL=https://shared.example\nSUPABASE_DASHBOARD_URL=https://dashboard.example\n", - ), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://shared.example\nSUPABASE_DASHBOARD_URL=https://dashboard.example\n", ); - yield* Effect.tryPromise(() => - writeFile( - join(packageRoot, "supabase", ".env.local"), - "SUPABASE_API_URL=https://local.example\n", - ), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env.local"), + "SUPABASE_API_URL=https://local.example\n", ); const { cliSettings, cliProjectContext } = yield* Effect.gen(function* () { @@ -142,7 +146,7 @@ describe("cliSettingsLayer", () => { cliSettings: yield* CliSettings, cliProjectContext: yield* CliProjectContext, }; - }).pipe(Effect.provide(buildLayer({ cwd }))); + }).pipe(Effect.provide(buildLayer(path, { cwd }))); expect(cliSettings.apiUrl).toBe("https://local.example"); expect(cliSettings.dashboardUrl).toBe("https://dashboard.example"); @@ -150,36 +154,33 @@ describe("cliSettingsLayer", () => { if (Option.isSome(cliProjectContext.paths)) { expect(cliProjectContext.paths.value.projectRoot).toBe(packageRoot); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("lets ambient env override discovered project env", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + it.live("lets ambient env override discovered project env", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(projectRoot, "supabase", ".env"), - "SUPABASE_API_URL=https://from-dotenv.example\nSUPABASE_ACCESS_TOKEN=sbp_dotenv\n", - ), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://from-dotenv.example\nSUPABASE_ACCESS_TOKEN=sbp_dotenv\n", ); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", ".env.local"), "SUPABASE_ACCESS_TOKEN=sbp_local\n"), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env.local"), + "SUPABASE_ACCESS_TOKEN=sbp_local\n", ); - const cliSettings = yield* Effect.gen(function* () { - return yield* CliSettings; - }).pipe( + const cliSettings = yield* CliSettings.pipe( Effect.provide( - buildLayer({ + buildLayer(path, { cwd: projectRoot, env: { SUPABASE_API_URL: "https://from-ambient.example", @@ -194,25 +195,24 @@ describe("cliSettingsLayer", () => { if (Option.isSome(cliSettings.accessToken)) { expect(Redacted.value(cliSettings.accessToken.value)).toBe("sbp_ambient"); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("has no PostHog key when nothing is injected or overridden", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("has no PostHog key when nothing is injected or overridden", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const cliSettings = yield* CliSettings.pipe( + Effect.provide(buildLayer(path, { cwd: tempDir })), + ); expect(Option.isNone(cliSettings.telemetryPosthogKey)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.effect("preserves empty runtime settings as present options", () => { const settingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(mockRuntimeInfo({ cwd: "/test/cwd", homeDir: "/test/home" })), Layer.provide(mockCliProjectContext()), Layer.provide( @@ -237,74 +237,77 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.provide(settingsLayer)); }); - it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { + cwd: tempDir, + env: { + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_env_override", + }, + }), + ), + ); expect(cliSettings.telemetryPosthogKey).toEqual(Option.some("phc_env_override")); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_env_override", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("uses SUPABASE_HOME (trimmed) when configured", () => { - const tempDir = makeTempDir(); - const supabaseHome = join(tempDir, "custom-supabase-home"); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("uses SUPABASE_HOME (trimmed) when configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const supabaseHome = path.join(tempDir, "custom-supabase-home"); + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { cwd: tempDir, env: { SUPABASE_HOME: ` ${supabaseHome} ` } }), + ), + ); expect(cliSettings.supabaseHome).toBe(supabaseHome); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir, env: { SUPABASE_HOME: ` ${supabaseHome} ` } })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); for (const value of ["", " "]) { it.live( `falls back to /.supabase when SUPABASE_HOME is ${JSON.stringify(value)}`, - () => { - const tempDir = makeTempDir(); - const homeDir = join(tempDir, "home"); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const homeDir = path.join(tempDir, "home"); + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { cwd: tempDir, homeDir, env: { SUPABASE_HOME: value } }), + ), + ); - expect(cliSettings.supabaseHome).toBe(join(homeDir, ".supabase")); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir, homeDir, env: { SUPABASE_HOME: value } })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + expect(cliSettings.supabaseHome).toBe(path.join(homeDir, ".supabase")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); } - it.live("uses the build-injected PostHog key and host when no runtime override is set", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("uses the build-injected PostHog key and host when no runtime override is set", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { + cwd: tempDir, + env: { + SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", + SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", + }, + }), + ), + ); expect(cliSettings.telemetryPosthogHost).toBe("https://build-posthog.example"); expect(cliSettings.telemetryPosthogKey).toEqual(Option.some("phc_build_key")); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", - SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts b/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts index 66fa7b3c0f..3e2a497c00 100644 --- a/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts +++ b/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, FileSystem, Exit, Layer, Option, PlatformError } from "effect"; +import { + Cause, + Effect, + FileSystem, + Exit, + Layer, + Option, + Path, + PlatformError, + Schema, +} from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; @@ -17,19 +23,22 @@ import { ProjectNotLinkedError, } from "./project-link-state.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-link-state-")); -} - -function buildLayer(opts: { - cwd: string; - env?: Record; - homeDir?: string; - fs?: Layer.Layer; -}) { +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-link-state-" }), +); + +function buildLayer( + path: Path.Path, + opts: { + cwd: string; + env?: Record; + homeDir?: string; + fs?: Layer.Layer; + }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -38,6 +47,7 @@ function buildLayer(opts: { Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), ); @@ -85,32 +95,32 @@ const SAMPLE_STATE = { } as const; describe("projectLinkStateLayer", () => { - it.live("surfaces a clear permission failure", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const linkPath = join(projectRoot, ".supabase", "project.json"); - const fsLayer = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - remove: () => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "remove", - description: "permission denied", - pathOrDescriptor: linkPath, - }), - ), - }), - ); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - const layer = buildLayer({ cwd: projectRoot, fs: fsLayer }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + it.live("surfaces a clear permission failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const linkPath = path.join(projectRoot, ".supabase", "project.json"); + const fsLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + remove: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "permission denied", + pathOrDescriptor: linkPath, + }), + ), + }), + ); + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + const layer = buildLayer(path, { cwd: projectRoot, fs: fsLayer }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const exit = yield* Effect.exit(linkState.clear); expect(Exit.isFailure(exit)).toBe(true); @@ -119,29 +129,26 @@ describe("projectLinkStateLayer", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("saves and loads repo-local project link state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + it.live("saves and loads repo-local project link state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); const loaded = yield* linkState.load; @@ -151,36 +158,31 @@ describe("projectLinkStateLayer", () => { expect(loaded.value).toEqual(SAMPLE_STATE); } - const rawFile = yield* Effect.tryPromise(() => - readFile(cliProjectHome.projectLinkPath, "utf8"), - ); + const rawFile = yield* fs.readFileString(cliProjectHome.projectLinkPath); expect(rawFile).toContain('"project":'); expect(rawFile).toContain('"active_branch":'); - const raw = JSON.parse(rawFile) as typeof SAMPLE_STATE; + const raw = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(rawFile); expect(raw).toEqual(SAMPLE_STATE); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("clears repo-local link state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + it.live("clears repo-local link state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); yield* linkState.clear; @@ -188,24 +190,21 @@ describe("projectLinkStateLayer", () => { const loaded = yield* linkState.load; expect(Option.isNone(loaded)).toBe(true); - yield* Effect.tryPromise(() => readFile(cliProjectHome.projectLinkPath, "utf8")).pipe( - Effect.flip, - Effect.asVoid, - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + yield* fs.readFileString(cliProjectHome.projectLinkPath).pipe(Effect.flip, Effect.asVoid); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("fails with a tagged error when repo-local link state is malformed", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("fails with a tagged error when repo-local link state is malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".supabase"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".supabase"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); const { cliProjectHome, linkState } = yield* Effect.gen(function* () { return { cliProjectHome: yield* CliProjectHome, @@ -213,7 +212,7 @@ describe("projectLinkStateLayer", () => { }; }).pipe(Effect.provide(layer)); - yield* Effect.tryPromise(() => writeFile(cliProjectHome.projectLinkPath, "{not-json")); + yield* fs.writeFileString(cliProjectHome.projectLinkPath, "{not-json"); const exit = yield* linkState.load.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -228,43 +227,39 @@ describe("projectLinkStateLayer", () => { }); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("getActiveBranch returns none when not linked", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("getActiveBranch returns none when not linked", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const activeBranch = yield* linkState.getActiveBranch; expect(Option.isNone(activeBranch)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("getActiveBranch returns the persisted active_branch", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("getActiveBranch returns the persisted active_branch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); @@ -277,25 +272,23 @@ describe("projectLinkStateLayer", () => { is_default: true, }); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "setActiveBranch updates only active_branch, leaving project and versions unchanged", - () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); @@ -310,24 +303,21 @@ describe("projectLinkStateLayer", () => { expect(loaded.value.versions).toEqual(SAMPLE_STATE.versions); expect(loaded.value.fetchedAt).toBe(SAMPLE_STATE.fetchedAt); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const exit = yield* linkState .setActiveBranch({ ref: "branchrefabcdefghijk", name: "feature-x", is_default: false }) @@ -345,8 +335,6 @@ describe("projectLinkStateLayer", () => { }); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index 67be36cea4..7095246ead 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -1,5 +1,4 @@ -import { join } from "node:path"; -import { Option } from "effect"; +import { Option, type Path } from "effect"; /** * Resolves the global Supabase CLI state root. @@ -9,18 +8,24 @@ import { Option } from "effect"; * defaults to `/.supabase`. A pure function, so every caller resolves through it with * its own environment and home directory, keeping the contract in one place. */ -export const resolveSupabaseHomeValue = (value: Option.Option, homeDir: string): string => { +export const resolveSupabaseHomeValue = ( + path: Path.Path, + value: Option.Option, + homeDir: string, +): string => { const configured = Option.isSome(value) ? value.value.trim() : undefined; return configured !== undefined && configured.length > 0 ? configured - : join(homeDir, ".supabase"); + : path.join(homeDir, ".supabase"); }; export const resolveSupabaseHome = ( + path: Path.Path, env: Readonly>, homeDir: string, ): string => resolveSupabaseHomeValue( + path, env["SUPABASE_HOME"] === undefined ? Option.none() : Option.some(env["SUPABASE_HOME"]), homeDir, ); diff --git a/apps/cli/src/shared/config/supabase-home.unit.test.ts b/apps/cli/src/shared/config/supabase-home.unit.test.ts index 2302e42cf3..8a6ca37b9e 100644 --- a/apps/cli/src/shared/config/supabase-home.unit.test.ts +++ b/apps/cli/src/shared/config/supabase-home.unit.test.ts @@ -1,31 +1,54 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { expect, layer } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Path } from "effect"; import { resolveSupabaseHome } from "./supabase-home.ts"; -const HOME = join("/home", "test"); +layer(BunServices.layer)("resolveSupabaseHome", (it) => { + it.effect("returns SUPABASE_HOME when set to a non-empty value", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: "/custom/supabase" }, home)).toBe( + "/custom/supabase", + ); + }), + ); -describe("resolveSupabaseHome", () => { - it("returns SUPABASE_HOME when set to a non-empty value", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: "/custom/supabase" }, HOME)).toBe( - "/custom/supabase", - ); - }); + it.effect("trims surrounding whitespace from SUPABASE_HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: " /custom/supabase " }, home)).toBe( + "/custom/supabase", + ); + }), + ); - it("trims surrounding whitespace from SUPABASE_HOME", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: " /custom/supabase " }, HOME)).toBe( - "/custom/supabase", - ); - }); + it.effect("falls back to /.supabase when SUPABASE_HOME is unset", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, {}, home)).toBe(path.join(home, ".supabase")); + }), + ); - it("falls back to /.supabase when SUPABASE_HOME is unset", () => { - expect(resolveSupabaseHome({}, HOME)).toBe(join(HOME, ".supabase")); - }); + it.effect("falls back to /.supabase when SUPABASE_HOME is empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: "" }, home)).toBe( + path.join(home, ".supabase"), + ); + }), + ); - it("falls back to /.supabase when SUPABASE_HOME is empty", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: "" }, HOME)).toBe(join(HOME, ".supabase")); - }); - - it("falls back to /.supabase when SUPABASE_HOME is whitespace only", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: " " }, HOME)).toBe(join(HOME, ".supabase")); - }); + it.effect("falls back to /.supabase when SUPABASE_HOME is whitespace only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: " " }, home)).toBe( + path.join(home, ".supabase"), + ); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index 36dd647407..b606585446 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -22,6 +22,7 @@ function withEnv(env: Record) { Layer.provide( ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), ), + Layer.provide(BunServices.layer), ); } diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index a3780c7b52..1715c83bf1 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -53,6 +53,7 @@ function buildLayer(opts: { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(providerLayer), + Layer.provide(BunServices.layer), ); const telemetryLayer = telemetryRuntimeLayer.pipe( Layer.provide(configLayer), diff --git a/apps/cli/src/telemetry/telemetry-state.layer.ts b/apps/cli/src/telemetry/telemetry-state.layer.ts index 7dedabc190..48b603e828 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.ts @@ -26,7 +26,7 @@ const SCHEMA_VERSION = 1; const SESSION_ROTATION_MS = 30 * 60 * 1000; function telemetryPath(env: Record, pathSvc: Path.Path): string { - return pathSvc.join(supabaseHome(homedir(), env), "telemetry.json"); + return pathSvc.join(supabaseHome(pathSvc, homedir(), env), "telemetry.json"); } /** diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 54a5a6d9dc..d6c4ff3765 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -699,6 +699,7 @@ export function emptyEnv() { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ), ); } From 98cda96f069fbceb2748beba11660bca61b4efe4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 10:32:31 +0000 Subject: [PATCH 36/71] fix(stack): keep in-progress artifact extractions safe from the leftover reaper (#6881) ## Problem Native database starts occasionally failed on a cold artifact cache with `supabase-postgres: initialization script is missing`. The store extracted each artifact into a `...tmp` directory beside the cache entry. Every operation first reaps leftovers of that name older than 24 hours, judged by the directory's mtime. GNU tar sets the extraction target's mtime from the archive's `./` entry, and the slim archives carry epoch timestamps. So another process preparing the same artifact reaped an in-progress extraction. The extracting process then published the partly deleted tree. ## Change The staging directory is store-owned: sources materialize into a `content` child, which the store validates and renames into place. Nothing a source extracts can change the staging directory's mtime, so the reaper only removes genuine leftovers. --- .../stack/src/preparation/ArtifactStore.ts | 24 ++++--- .../preparation/artifacts.integration.test.ts | 67 +++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 2b8aaf2dd0..2b20d8c05e 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -71,6 +71,11 @@ type ArtifactStoreError = PreparationError | ArtifactIntegrityError; const ARTIFACT_FORMAT = "supabase-stack-artifact-v3"; const METADATA_NAME = ".artifact.json"; const EXECUTABLE_MODE = 0o755; +/** + * Child of the staging directory that sources materialize into: GNU tar resets its extraction + * target's mtime from the archive, which must not make a staging directory look like a leftover. + */ +const STAGING_CONTENT_NAME = "content"; type ArtifactPathKind = "file" | "directory" | "symlink"; @@ -778,11 +783,12 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( }), ), ); - const temporary = path.join(targetParent, `.${path.basename(target)}.${token}.tmp`); + const staging = path.join(targetParent, `.${path.basename(target)}.${token}.tmp`); + const content = path.join(staging, STAGING_CONTENT_NAME); const published = yield* Effect.gen(function* () { - yield* ensureDirectory(fs, path, temporary, cacheRoot); - const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); - yield* source.materialize(request, temporary, expectedSha256, onProgress).pipe( + yield* ensureDirectory(fs, path, content, cacheRoot); + const contentRoot = yield* ensureSafeRoot(fs, path, content, cacheRoot); + yield* source.materialize(request, content, expectedSha256, onProgress).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto), @@ -793,8 +799,8 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const runtimePaths = yield* validateFreshRuntimePaths( fs, path, - temporary, - temporaryRoot, + content, + contentRoot, request.requiredRuntimePaths, ); const runtimeKinds = Object.fromEntries( @@ -802,7 +808,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( ); yield* writeMetadataSync( fs, - path.join(temporary, METADATA_NAME), + path.join(content, METADATA_NAME), metadataFor(request, expectedSha256, runtimeKinds), ); if (request.executablePath !== undefined) { @@ -820,7 +826,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( } const beforePublish = yield* inspectCache(); if (Option.isSome(beforePublish)) return beforePublish.value; - const rename = mapFs(temporary, "publish artifact", fs.rename(temporary, target)).pipe( + const rename = mapFs(content, "publish artifact", fs.rename(content, target)).pipe( Effect.as(undefined), ); const recoverPublish = (): Effect.Effect => @@ -832,7 +838,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const recovered: Effect.Effect = rename.pipe(Effect.catch(recoverPublish)); return yield* recovered; - }).pipe(Effect.onExit(() => cleanup(fs, temporary))); + }).pipe(Effect.onExit(() => cleanup(fs, staging))); if (published !== undefined) return published; return { key: request.key, diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 28a4a6bab6..628d430bf4 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -789,4 +789,71 @@ describe("orphaned temp/quarantine sweep", () => { }), ), ); + + it.live("never reaps an in-progress extraction whose source backdated its own destination", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-reaper-race-", + }); + const backdated = yield* Deferred.make(); + const secondReachedMaterialize = yield* Deferred.make(); + let materializeCalls = 0; + const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), + materialize: (_entry, destination, expectedSha256) => + Effect.gen(function* () { + materializeCalls += 1; + if (materializeCalls > 1) { + // The second preparation arrives here after running the reaper; it fails instead + // of racing the first to publish, which keeps the interleaving deterministic. + yield* Deferred.succeed(secondReachedMaterialize, undefined); + return yield* new PreparationError({ + message: "the second preparation stops before publishing", + }); + } + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + yield* verifySha256(archive, expectedSha256).pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); + // GNU tar sets an extraction destination's own mtime from the archive's `./` + // entry; the slim-services archives carry epoch mtimes there. + yield* fs.utimes(destination, 0, 0); + yield* Deferred.succeed(backdated, undefined); + yield* Deferred.await(secondReachedMaterialize); + }).pipe( + Effect.mapError((cause) => + cause instanceof PreparationError || cause instanceof ArtifactIntegrityError + ? cause + : new PreparationError({ + message: `materialization failed: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + + const first = yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.await(backdated); + + // Runs the same leftover reaper the first operation's staging directory is exposed to; + // it refuses its own materialization once it gets there, so it can never win a race to + // publish and mask a reap that already happened. + const second = yield* store.prepare(request).pipe(Effect.exit); + expect(Exit.isFailure(second)).toBe(true); + + const published = yield* Fiber.join(first); + expect(published.outcome).toBe("downloaded"); + expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); + expect(yield* fs.readFileString(`${published.path}/bin/postgres`)).toBe("native postgres"); + expect(yield* fs.readFileString(`${published.path}/etc/postgres.conf`)).toBe("config"); + }), + ), + ); }); From d38ccd7e51ed003fa6b78f990919d68e07499681 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 10:46:28 +0000 Subject: [PATCH 37/71] test(repo): replace real-time waits in slow CLI and stack tests (#6862) ## Problem Several tests exercised timeout and retry paths by waiting out the real delays: - `start` integration tests asserted never-healthy outcomes by sitting through the full default health-check budget. `waitForHealthyServices` had no way to receive a shorter default, so each one waited out the whole loop. - The image prepull failure tests slept through the real Docker pull backoff. - Container integration workloads ignored SIGTERM, so every `docker stop` waited out its full grace period. ## Change - The default health-check budget is now `HealthCheckTimeoutSeconds`, an Effect reference that still defaults to 30. The start tests that assert never-healthy outcomes provide 0, which keeps the single probe they depend on. A unit test fixes the production default under `TestClock`. - The Postgres `health_timeout` tests use `0s` for the same reason. - The prepull failure tests drive the backoff with `TestClock`. - Container test workloads exit on SIGTERM. --- .../db-bootstrap/health-check.ts | 39 ++++---- .../db-bootstrap/health-check.unit.test.ts | 24 +++++ .../db-bootstrap/image-prepull.unit.test.ts | 97 +++++++++++-------- .../commands/start/start.integration.test.ts | 63 +++++++----- .../src/runtime/Container.integration.test.ts | 18 ++-- 5 files changed, 150 insertions(+), 91 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.ts index 1b8757bcaa..c2bd3dabff 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -5,7 +5,7 @@ * final timeout's failures surface to the caller. */ -import { Data, Duration, Effect, Schedule, Stream } from "effect"; +import { Context, Data, Duration, Effect, Schedule, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -22,8 +22,11 @@ import { kongAuthHeaders } from "../kong-auth.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** Default retry budget when the caller doesn't specify one. */ -const HEALTH_CHECK_TIMEOUT_SECONDS = 30; +/** Retry budget, in seconds, for health waits whose caller passes no `timeoutSeconds`. */ +export const HealthCheckTimeoutSeconds = Context.Reference( + "supabase/db-bootstrap/HealthCheckTimeoutSeconds", + { defaultValue: () => 30 }, +); /** Caps a single HTTP readiness probe so a hung response cannot stall the retry loop. */ const HTTP_PROBE_TIMEOUT_SECONDS = 10; @@ -269,7 +272,6 @@ export function waitForHealthyServices( containerIds: ReadonlyArray, opts: WaitForHealthyServicesOptions = {}, ): Effect.Effect { - const timeoutSeconds = opts.timeoutSeconds ?? HEALTH_CHECK_TIMEOUT_SECONDS; const postgrest = opts.postgrest; const edgeRuntime = opts.edgeRuntime; @@ -284,6 +286,7 @@ export function waitForHealthyServices( }; return Effect.gen(function* () { + const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); let stillWatching = containerIds; // Each round narrows `stillWatching` to just the containers that failed, so a container @@ -405,19 +408,19 @@ export function waitForShadowReady( connConfig: PgConnInput, opts: WaitForShadowReadyOptions = {}, ): Effect.Effect { - const timeoutSeconds = opts.timeoutSeconds ?? HEALTH_CHECK_TIMEOUT_SECONDS; - - // Twice the second-counted budget: 500ms spacing would otherwise exhaust `timeoutSeconds` - // retries in half the wall time of a 1-second poll. - const schedule = Schedule.max([ - Schedule.spaced("500 millis"), - Schedule.recurs(timeoutSeconds * 2), - ]); - const boundSeconds = timeoutSeconds + SHADOW_READY_CONNECT_TIMEOUT_SECONDS; - - // Per-evaluation state: the retry rounds within one evaluation share the latest failure for - // the timeout diagnostic, while re-evaluating the returned Effect starts from a fresh slot. - return Effect.suspend(() => { + return Effect.gen(function* () { + const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); + + // Twice the second-counted budget: 500ms spacing would otherwise exhaust `timeoutSeconds` + // retries in half the wall time of a 1-second poll. + const schedule = Schedule.max([ + Schedule.spaced("500 millis"), + Schedule.recurs(timeoutSeconds * 2), + ]); + const boundSeconds = timeoutSeconds + SHADOW_READY_CONNECT_TIMEOUT_SECONDS; + + // Per-evaluation state: the retry rounds within one evaluation share the latest failure for + // the timeout diagnostic, while re-evaluating the returned Effect starts from a fresh slot. let lastFailure: ShadowReadyFailure | undefined; const probe: Effect.Effect = Effect.gen(function* () { @@ -439,7 +442,7 @@ export function waitForShadowReady( ), ); - return probe.pipe( + return yield* probe.pipe( Effect.retry({ schedule, while: (failure) => !failure.fatal }), Effect.timeoutOrElse({ duration: Duration.seconds(boundSeconds), diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts index 24126157ea..8c0a2cda72 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts @@ -277,6 +277,30 @@ describe("waitForHealthyServices", () => { }), ); + it.effect("keeps retrying for 30 seconds when the caller passes no timeout", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningStarting); + + const fiber = yield* waitForHealthyServices(mock.spawner, ["supabase_kong_proj"]).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + for (let second = 0; second < 29; second++) { + yield* TestClock.adjust("1 seconds"); + } + const pendingAfter29Seconds = fiber.pollUnsafe(); + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(pendingAfter29Seconds).toBeUndefined(); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect( + mock.spawned.filter((args) => args[0] === "container" && args[1] === "inspect"), + ).toHaveLength(31); + }), + ); + it.effect("dumps container logs to stderr on a genuine timeout", () => Effect.gen(function* () { const mock = mockHealthSpawner(() => notRunning); diff --git a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts index 359cb6ec70..f048dbf70c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Ref, Sink, Stream } from "effect"; +import { Deferred, Effect, Fiber, Ref, Sink, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ImagePrepullError, ensureImagesCached } from "./image-prepull.ts"; @@ -48,6 +49,26 @@ function mockSpawner( }; } +const backoffClockLimitSeconds = 120; + +/** Steps `TestClock` a second at a time through the pull backoff until `fiber` settles. */ +const joinAdvancingClock = (fiber: Fiber.Fiber) => + Effect.gen(function* () { + for ( + let second = 0; + second < backoffClockLimitSeconds && fiber.pollUnsafe() === undefined; + second++ + ) { + yield* TestClock.adjust("1 seconds"); + } + if (fiber.pollUnsafe() === undefined) { + return yield* Effect.die( + `fiber still pending after ${backoffClockLimitSeconds} virtual seconds of pull backoff`, + ); + } + return yield* Fiber.join(fiber); + }); + describe("ensureImagesCached", () => { it.live("dedupes images before resolving, returning original ref -> resolved URL", () => { const mock = mockSpawner((args) => { @@ -125,11 +146,8 @@ describe("ensureImagesCached", () => { }), ); - // Every pull attempt fails, driving the real `DOCKER_PULL_RETRY_DELAYS_MS` backoff to - // exhaustion across all 3 registry candidates (~36s) — needs more than Vitest's 5s default. - it.live( - "aggregates every failed image's message into one combined error", - () => { + it.effect("aggregates every failed image's message into one combined error", () => + Effect.gen(function* () { const mock = mockSpawner((args) => { if (args[0] === "image" && args[1] === "inspect") { return { @@ -141,45 +159,44 @@ describe("ensureImagesCached", () => { return { exitCode: 1 }; }); - return ensureImagesCached(mock.spawner, ["supabase/a:1", "supabase/b:1"]).pipe( - Effect.flip, - Effect.map((error) => { - expect(error).toBeInstanceOf(ImagePrepullError); - expect(error.message).toContain("supabase/a:1"); - expect(error.message).toContain("supabase/b:1"); - }), + const fiber = yield* ensureImagesCached(mock.spawner, ["supabase/a:1", "supabase/b:1"]).pipe( + Effect.forkChild({ startImmediately: true }), ); - }, - 60_000, + const error = yield* joinAdvancingClock(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ImagePrepullError); + expect(error.message).toContain("supabase/a:1"); + expect(error.message).toContain("supabase/b:1"); + }), ); - it.live( + it.effect( "appends the install hint once when a failure indicates the daemon is unreachable", - () => { - const mock = mockSpawner((args) => { - if (args[0] === "image" && args[1] === "inspect") { - return { - exitCode: 1, - stderr: `Error response from daemon: No such image: ${args[2]}`, - }; - } - if (args[0] === "pull") { - return { - exitCode: 1, - stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", - }; - } - return { exitCode: 1 }; - }); + () => + Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") { + return { + exitCode: 1, + stderr: `Error response from daemon: No such image: ${args[2]}`, + }; + } + if (args[0] === "pull") { + return { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", + }; + } + return { exitCode: 1 }; + }); - return ensureImagesCached(mock.spawner, ["supabase/a:1"]).pipe( - Effect.flip, - Effect.map((error) => { - expect(error.message).toContain("Docker Desktop is a prerequisite for local development"); - }), - ); - }, - 60_000, + const fiber = yield* ensureImagesCached(mock.spawner, ["supabase/a:1"]).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const error = yield* joinAdvancingClock(fiber).pipe(Effect.flip); + + expect(error.message).toContain("Docker Desktop is a prerequisite for local development"); + }), ); it.live("resolves an empty map for an empty image list without spawning anything", () => { diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index e193f10c95..4ed3356c11 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -47,6 +47,7 @@ import { } from "../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { serviceContainerIds, serviceContainerName } from "../../command-internal/docker-ids.ts"; +import { HealthCheckTimeoutSeconds } from "../../command-internal/db-bootstrap/health-check.ts"; import { DbConnection, type DbSession } from "../../command-internal/db-connection.service.ts"; import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; import { START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; @@ -3278,8 +3279,7 @@ content_path = "./supabase/templates/custom_notice.html" "fails and rolls back when Postgres itself never becomes healthy within its configured health_timeout", () => Effect.gen(function* () { - // `db.health_timeout` is config.toml-configurable, unlike the generic 30s - // `serviceTimeout` other services wait on, so this keeps the scenario fast. + // A zero `db.health_timeout` limits Postgres's wait to a single probe. const neverHealthy = new Set(); const base = defaultRoute({ neverHealthy }); const route = (args: ReadonlyArray): RouteResult => { @@ -3290,7 +3290,7 @@ content_path = "./supabase/templates/custom_notice.html" return base(args); }; const { layer, child } = yield* setup({ - configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "2s"\n', + configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "0s"\n', route, }); @@ -3328,7 +3328,7 @@ content_path = "./supabase/templates/custom_notice.html" return base(args); }; const { layer, out, child, analytics } = yield* setup({ - configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "2s"\n', + configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "0s"\n', route, }); @@ -3352,9 +3352,8 @@ content_path = "./supabase/templates/custom_notice.html" ); // Real time, not `it.effect`/`TestClock`: `start` performs genuine async I/O that never - // resolves under a virtualized clock. `waitForHealthyServices` has no config-configurable - // timeout seam here (it falls back to the hardcoded 30s default), hence the generous - // real-time budget. + // resolves under a virtualized clock. A zero `HealthCheckTimeoutSeconds` keeps the single + // probe that finds auth unhealthy and skips the default retry budget. it.live( "fails and rolls back when a non-Postgres service never becomes healthy within the timeout (no --ignore-health-check)", () => @@ -3383,16 +3382,18 @@ content_path = "./supabase/templates/custom_notice.html" } expect(out.stderrText).not.toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); }); // Real time, not `it.effect`/`TestClock`: genuine async I/O deep inside the forked effect // (HTTP requests and `resolveDbImage`'s file read) needs real Node // event-loop turns to settle, so a virtualized clock would never let the fiber reach the - // health-check phase. Exercises the real 30s `serviceTimeout` bulk health-check wait, hence - // the generous timeout. + // health-check phase. it.live( "exits 0 on --ignore-health-check when a non-Postgres container never turns healthy, without rolling back", () => @@ -3431,8 +3432,11 @@ content_path = "./supabase/templates/custom_notice.html" // `cli_stack_started` never fires on the ignored-unhealthy fallthrough — only a genuine // bulk health-check success reaches that capture. expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); describe("--ignore-health-check storage-only recheck and seed on a fresh volume", () => { @@ -3463,8 +3467,11 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); it.live( @@ -3507,8 +3514,11 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).not.toContain("Do you want to prune it?"); }).pipe(Effect.provide(layer)), ); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); it.live( @@ -3573,13 +3583,13 @@ content_path = "./supabase/templates/custom_notice.html" } expect(rollbackWasAttempted(child.spawned)).toBe(true); expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); - // Both the main bulk health check (auth) and this storage-only recheck run out their own - // full ~30s real-time retry budget here, hence the doubled timeout relative to every other - // real-time health-check test in this file. it.live( "falls through to the original warning without attempting to seed when the storage recheck itself never turns healthy", () => @@ -3609,8 +3619,11 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 90_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); }); @@ -5001,7 +5014,7 @@ content_path = "./supabase/templates/custom_notice.html" () => withEnvVar( "SUPABASE_DB_HEALTH_TIMEOUT", - "2s", + "0s", Effect.gen(function* () { const neverHealthy = new Set(); const base = defaultRoute({ neverHealthy }); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index e49294b952..f7e945933e 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -30,6 +30,8 @@ const image = await Effect.gen(function* () { const version = yield* fs.readFileString(file); return `oven/bun:${version.trim()}-slim`; }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); +const stoppableIdleScript = + "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"; class ContainerTestError extends Data.TaggedError("ContainerTestError")<{ readonly message: string; @@ -158,7 +160,7 @@ describe("container process adapter", () => { project: "My Cool App", service: "auth", env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); expect(process.id).toMatch(/^supabase-My-Cool-App-auth-[0-9a-f]{12}$/u); const labels = yield* inspectLabels(process.id); @@ -353,7 +355,7 @@ describe("container process adapter", () => { stackId: "x".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* removeExternally(process.id); yield* process.stop; @@ -385,7 +387,7 @@ describe("container process adapter", () => { stackId: "k".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; yield* process.remove; @@ -422,7 +424,7 @@ describe("container process adapter", () => { stackId: "l".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const removeResult = yield* process.remove.pipe(Effect.exit); @@ -464,7 +466,7 @@ describe("container process adapter", () => { stackId: "m".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const removeResult = yield* process.remove.pipe(Effect.exit); @@ -508,7 +510,7 @@ describe("container process adapter", () => { stackId: "n".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; yield* process.remove; @@ -547,7 +549,7 @@ describe("container process adapter", () => { stackId: "p".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const removeResult = yield* process.remove.pipe(Effect.exit); @@ -594,7 +596,7 @@ describe("container process adapter", () => { stackId: "o".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const remover = yield* process.remove.pipe( From 621768f7918c3b04f467ce45bbb4d680af4aaf25 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:35:23 +0000 Subject: [PATCH 38/71] fix(stack): release log followers on exit (CLI-2521) (#6861) ## TL;DR stops the stack runtime from signalling a reused pid when a container log stream dies long before its service stops ## what might hurt? the stack runtime spawned `docker logs --follow` into the service launch scope when that follower exited non zero while the container kept running, its release waited for service stop and then sent `kill(-pid, SIGTERM)` to a pid that an unrelated process could own by then. ## sorted by: giving the follower its own scope that closes as soon as it exits, so the release lands within milliseconds of the exit. a follower still running at service stop is killed exactly as before, and the serve tests now also pin the per attempt log scoping from #6594 ## ref: - related: https://github.com/supabase/cli/issues/6858 - missed in: https://github.com/supabase/cli/pull/6684 --- .../functions/serve/serve.integration.test.ts | 16 ++++ .../src/runtime/Container.integration.test.ts | 85 +++++++++++++++++++ packages/stack/src/runtime/Container.ts | 28 ++++-- 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 6b5215fb2a..7c176ed579 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -349,6 +349,8 @@ function mockFileWatcher(expectedPaths: ReadonlyArray = []) { function mockDockerLogSpawner(behaviors: ReadonlyArray) { const spawned: Array<{ command: string; args: ReadonlyArray }> = []; let index = 0; + let liveHandles = 0; + let maxLiveHandles = 0; return { layer: Layer.succeed( @@ -364,6 +366,16 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray) { args: [...command.args], }; spawned.push(record); + yield* Effect.acquireRelease( + Effect.sync(() => { + liveHandles += 1; + maxLiveHandles = Math.max(maxLiveHandles, liveHandles); + }), + () => + Effect.sync(() => { + liveHandles -= 1; + }), + ); const behavior = behaviors[Math.min(index, behaviors.length - 1)] ?? {}; index += 1; if (behavior.onSpawn !== undefined) yield* behavior.onSpawn(); @@ -396,6 +408,9 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray) { get spawned() { return spawned; }, + get maxLiveHandles() { + return maxLiveHandles; + }, }; } @@ -2629,6 +2644,7 @@ describe("functions serve integration", () => { expect(error.message).toContain("supabase_edge_runtime_test-project"); expect(error.message).toContain("5 times"); } + expect(childSpawner.maxLiveHandles).toBe(1); }); }, ); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index f7e945933e..55aaad7494 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -340,6 +340,71 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("releases a log follower that exits while its container keeps running", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const released = yield* Deferred.make(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeLogFollowerSpawner( + delegate, + released, + "console.error('injected log stream failure'); process.exit(1)", + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "s".repeat(64), + instanceId: "dropped-log-follower", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + yield* Deferred.await(released).pipe(Effect.timeout("10 seconds")); + expect( + yield* process.stderr.pipe( + Stream.decodeText, + Stream.mkString, + Effect.timeout("10 seconds"), + ), + ).toContain("injected log stream failure"); + expect(yield* running(process.id)).toBe(true); + yield* process.discard; + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("releases a still running log follower when its launch scope closes", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const released = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeLogFollowerSpawner(delegate, released, "setInterval(() => {}, 1000)"), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "t".repeat(64), + instanceId: "live-log-follower", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + expect(yield* Deferred.isDone(released)).toBe(false); + yield* process.discard; + }), + ); + expect(yield* Deferred.isDone(released)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("treats an externally removed container as already stopped", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -912,6 +977,26 @@ const makeStopFailureSpawner = ( return delegate.spawn(command); }); +const makeLogFollowerSpawner = ( + delegate: ChildProcessSpawnerService["Service"], + released: Deferred.Deferred, + script: string, +) => + ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.command === "docker" && + command.args[0] === "logs" + ) + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined)); + return yield* delegate.spawn( + ChildProcess.make(process.execPath, ["-e", script], { stdin: "ignore" }), + ); + }); + return delegate.spawn(command); + }); + const makeLostRemoveResultSpawner = ( delegate: ChildProcessSpawnerService["Service"], lostResult: Ref.Ref, diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index c300a013bd..8217ad3461 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -559,13 +559,27 @@ export const makeContainerRuntime = (options: { } const logProcess = attached ?? - (yield* spawner - .spawn( - ChildProcess.make(options.engine, ["logs", "--follow", name], { - stdin: "ignore", - }), - ) - .pipe(Effect.mapError((cause) => errorFor("logs", cause)))); + (yield* Effect.gen(function* () { + // Released on exit, since a release left for service stop can hit a reused pid. + const followerScope = yield* Scope.fork(owner); + const follower = yield* spawner + .spawn( + ChildProcess.make(options.engine, ["logs", "--follow", name], { + stdin: "ignore", + }), + ) + .pipe( + Scope.provide(followerScope), + Effect.mapError((cause) => errorFor("logs", cause)), + ); + yield* Effect.forkIn( + Effect.exit(follower.exitCode).pipe( + Effect.andThen(Scope.close(followerScope, Exit.void)), + ), + owner, + ); + return follower; + })); return { ...partial, ports, From f6d5078777f6f32a60b8be35903017127c83823b Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:23:46 +0000 Subject: [PATCH 39/71] refactor(cli): cover `shared/runtime` with effect lint (CLI-2519) (#6857) ## TL;DR brings `shared/runtime` under the effect lint ## whats introduced? effect lint applied to the runtime layers and their tests: - the `file-watcher` entry collapses into one `shared/runtime/**` entry - `tty` checks for a piped stdout through `Bun.file(1).stat()`, which runs the same fstat as `fstatSync(1)` on every platform, so the windows pipe check in `db dump` is unchanged - `command-runtime` mints its run id from the `node:crypto` import instead of the global - `stdin` keeps its fd 0 `createReadStream` behind one scoped disable, since no effect or bun stream reads fd 0 within the bound from https://github.com/supabase/cli/issues/6287 - the stack e2e cleanup and stdin tests run as effects, and the stdin subprocess tests kill their children through `acquireRelease` - the `db dump` pipe probes run the production `Tty` layer instead of their own `fstatSync(1)` copy - the browser spawner failure is a typed `PlatformError` instead of an `any` cast ## ref: - closes: CLI-2519 --- .oxlintrc.effect.json | 2 +- .../config/diff/diff.integration.test.ts | 4 +- .../config/push/push.integration.test.ts | 2 +- .../commands/db/dump/dump.integration.test.ts | 44 +- .../commands/db/test/test.integration.test.ts | 2 +- .../feedback/add/add.integration.test.ts | 2 +- .../delete/delete.integration.test.ts | 2 +- .../delete/delete.integration.test.ts | 3 +- .../download/download.integration.test.ts | 3 +- .../commands/link/link.integration.test.ts | 5 +- .../commands/pull/pull.integration.test.ts | 2 +- .../whoami/whoami.integration.test.ts | 3 +- .../shared/runtime/browser.layer.unit.test.ts | 13 +- .../shared/runtime/command-runtime.layer.ts | 13 +- .../command-runtime.layer.unit.test.ts | 15 +- .../process-control.layer.unit.test.ts | 2 +- .../runtime/stack-e2e-cleanup.unit.test.ts | 686 +++++++++--------- .../shared/runtime/stdin.integration.test.ts | 212 ++++-- apps/cli/src/shared/runtime/stdin.layer.ts | 23 +- apps/cli/src/shared/runtime/tty.layer.ts | 28 +- .../command-instrumentation.unit.test.ts | 19 +- .../telemetry/command-telemetry.unit.test.ts | 139 ++-- apps/cli/tests/helpers/notebooks.ts | 2 +- 23 files changed, 698 insertions(+), 528 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index f3bae1efe0..d96a832831 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -15,7 +15,7 @@ "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", - "!apps/cli/src/shared/runtime/file-watcher.service.ts", + "!apps/cli/src/shared/runtime/**", "!apps/cli/src/shared/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 9a02b6508c..b2d707b533 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -1158,7 +1158,7 @@ describe("config diff telemetry wiring", () => { const wiringLayer = (analytics: ReturnType, projectRef: string) => Layer.mergeAll( setup({ toml: 'project_id = "test"\n', analytics }).layer, - commandRuntimeLayer(["config", "diff"]), + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--project-ref", projectRef]), }), @@ -1208,7 +1208,7 @@ describe("config diff -o/--output wrapper wiring", () => { Effect.provide( Layer.mergeAll( layer, - commandRuntimeLayer(["config", "diff"]), + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "diff", "-o", "table"]) }), ), ), diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 61edcf9bff..4cddc7ba2e 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3600,7 +3600,7 @@ describe("config push telemetry wiring", () => { const wiringLayer = (analytics: ReturnType, projectRef: string) => Layer.mergeAll( setup({ toml: `project_id = "test"\n`, yes: true, analytics }).layer, - commandRuntimeLayer(["config", "push"]), + commandRuntimeLayer(["config", "push"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "push", "--project-ref", projectRef]), }), diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index b271d4e65e..bcd3872bbe 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -1,7 +1,18 @@ import process from "node:process"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Stream } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schema, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; @@ -1095,13 +1106,24 @@ describe("db dump integration", () => { // A shell pipeline gets a genuine FIFO for the pipe probe below; Bun's spawnSync // "pipe" stdio is a socketpair, which fstats as a socket instead. - const PROBE = 'process.stdout.write(String(require("node:fs").fstatSync(1).isFIFO()));'; + const ttyProbe = Effect.gen(function* () { + const path = yield* Path.Path; + const module = (file: string) => + path + .fromFileUrl(new URL(`../../../shared/runtime/${file}`, import.meta.url)) + .pipe(Effect.flatMap(Schema.encodeEffect(Schema.fromJsonString(Schema.String)))); + const service = yield* module("tty.service.ts"); + const layer = yield* module("tty.layer.ts"); + return `import { Effect } from "effect"; import { Tty } from ${service}; import { ttyLayer } from ${layer}; Effect.runPromise(Tty.pipe(Effect.provide(ttyLayer))).then((tty) => process.stdout.write(String(tty.stdoutIsPipe)));`; + }); it.live.skipIf(process.platform === "win32")("classifies a real piped stdout as a pipe", () => Effect.gen(function* () { + const probe = yield* ttyProbe; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-c", `"${process.execPath}" -e '${PROBE}' | cat`], { + ChildProcess.make("/bin/sh", ["-c", '"$1" -e "$2" | cat', "sh", process.execPath, probe], { + cwd: import.meta.dirname, stdin: "ignore", stderr: "ignore", }), @@ -1121,13 +1143,19 @@ describe("db dump integration", () => { Effect.gen(function* () { const path = yield* Path.Path; const file = path.join(tmp.current, "pipe-probe.txt"); + const probe = yield* ttyProbe; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const exitCode = yield* spawner.exitCode( - ChildProcess.make("/bin/sh", ["-c", `"${process.execPath}" -e '${PROBE}' > "${file}"`], { - stdin: "ignore", - stdout: "ignore", - stderr: "inherit", - }), + ChildProcess.make( + "/bin/sh", + ["-c", '"$1" -e "$2" > "$3"', "sh", process.execPath, probe, file], + { + cwd: import.meta.dirname, + stdin: "ignore", + stdout: "ignore", + stderr: "inherit", + }, + ), ); expect(exitCode).toBe(0); expect(yield* readUtf8(file)).toBe("false"); diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index f3e5e33f01..a960da47f7 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -184,7 +184,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: [] }), Stdio.layerTest({ args: Effect.succeed(args) }), - commandRuntimeLayer(["db", "test"]), + commandRuntimeLayer(["db", "test"]).pipe(Layer.provide(BunServices.layer)), BunServices.layer, ); return { layer, out, analytics, processControl, connection, docker }; diff --git a/apps/cli/src/commands/feedback/add/add.integration.test.ts b/apps/cli/src/commands/feedback/add/add.integration.test.ts index 018b56998c..9eb95a0151 100644 --- a/apps/cli/src/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/commands/feedback/add/add.integration.test.ts @@ -171,7 +171,7 @@ function setupFeedbackHandler( base.layer, analytics.layer, processControl.layer, - commandRuntimeLayer(["feedback", "add"]), + commandRuntimeLayer(["feedback", "add"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback", "add"])]) }), ); return { ...base, layer, analytics, processControl }; diff --git a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts index ffd821d53c..76caca4eaa 100644 --- a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts @@ -152,7 +152,7 @@ function setupFeedbackDeleteHandler( base.layer, analytics.layer, processControl.layer, - commandRuntimeLayer(["feedback", "delete"]), + commandRuntimeLayer(["feedback", "delete"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback", "delete", TOKEN])]) }), ); return { ...base, layer, analytics, processControl }; diff --git a/apps/cli/src/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/commands/functions/delete/delete.integration.test.ts index 8cf9331f33..cad2932b70 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -85,7 +86,7 @@ describe("functions delete", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), analytics, }), - commandRuntimeLayer(["functions", "delete"]), + commandRuntimeLayer(["functions", "delete"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed([ "functions", diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 91f6a68894..0c00600613 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; import { Config, @@ -1724,7 +1725,7 @@ describe("functions download", () => { analytics, }), proxy.layer, - commandRuntimeLayer(["functions", "download"]), + commandRuntimeLayer(["functions", "download"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed([ "functions", diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 61c6f7d427..1ce4cae955 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -1459,7 +1460,7 @@ describe("link integration", () => { cliSettings, analytics, }), - commandRuntimeLayer(["link"]), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", VALID_REF]), }), @@ -1489,7 +1490,7 @@ describe("link integration", () => { cliSettings, analytics, }), - commandRuntimeLayer(["link"]), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", "my-branch"]), }), diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 634f7649b1..f3eba9ba8c 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -664,7 +664,7 @@ function setup(opts: SetupOpts = {}) { goOutput: opts.goOutput ?? Option.none(), }), machineErrorContextLayer, - commandRuntimeLayer(["pull"]), + commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer)), capturingStdio?.layer ?? Stdio.layerTest({ args: Effect.succeed(["pull"]) }), dbConfig.layer, pgDelta.layer, diff --git a/apps/cli/src/commands/whoami/whoami.integration.test.ts b/apps/cli/src/commands/whoami/whoami.integration.test.ts index 4de8aebb0f..0b0c38d299 100644 --- a/apps/cli/src/commands/whoami/whoami.integration.test.ts +++ b/apps/cli/src/commands/whoami/whoami.integration.test.ts @@ -1,5 +1,6 @@ import type { V1GetProfileOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Cause, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; import { GLOBAL_OUTPUT_FORMATS } from "../../command-internal/global-flags.ts"; @@ -227,7 +228,7 @@ describe("whoami integration", () => { Effect.provide( Layer.mergeAll( layer, - commandRuntimeLayer(["whoami"]), + commandRuntimeLayer(["whoami"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["whoami", "-o", goOutput]) }), ), ), diff --git a/apps/cli/src/shared/runtime/browser.layer.unit.test.ts b/apps/cli/src/shared/runtime/browser.layer.unit.test.ts index a782f5a1ef..fe5456620d 100644 --- a/apps/cli/src/shared/runtime/browser.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/browser.layer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer, Sink, Stream } from "effect"; +import { ConfigProvider, Effect, Layer, PlatformError, Sink, Stream } from "effect"; import { FileSystem } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; @@ -142,7 +142,16 @@ describe("Browser", () => { it.effect("errors are ignored when spawner fails", () => { const failingLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(new Error("spawn failed") as any)), + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ), + ), ); const configLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); const layer = Layer.mergeAll( diff --git a/apps/cli/src/shared/runtime/command-runtime.layer.ts b/apps/cli/src/shared/runtime/command-runtime.layer.ts index 021770dd2c..5969006dde 100644 --- a/apps/cli/src/shared/runtime/command-runtime.layer.ts +++ b/apps/cli/src/shared/runtime/command-runtime.layer.ts @@ -1,13 +1,14 @@ -import { Effect, Layer } from "effect"; +import { Crypto, Effect, Layer } from "effect"; import { CommandRuntime } from "./command-runtime.service.ts"; export const commandRuntimeLayer = (commandPath: ReadonlyArray) => Layer.effect( CommandRuntime, - Effect.sync(() => - CommandRuntime.of({ + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return CommandRuntime.of({ commandPath: [...commandPath], - commandRunId: crypto.randomUUID(), - }), - ), + commandRunId: yield* crypto.randomUUIDv4, + }); + }), ); diff --git a/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts b/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts index cfbb024fa1..9b459fd24c 100644 --- a/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Effect } from "effect"; import { commandRuntimeLayer } from "./command-runtime.layer.ts"; @@ -8,21 +9,21 @@ import { getCommandRuntimeSpanName, } from "./command-runtime.service.ts"; +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + describe("commandRuntimeLayer", () => { it.effect("generates a fresh command run id for each invocation", () => Effect.gen(function* () { - const first = yield* Effect.gen(function* () { - return yield* CommandRuntime; - }).pipe(Effect.provide(commandRuntimeLayer(["status"]))); - const second = yield* Effect.gen(function* () { - return yield* CommandRuntime; - }).pipe(Effect.provide(commandRuntimeLayer(["status"]))); + const first = yield* CommandRuntime.pipe(Effect.provide(commandRuntimeLayer(["status"]))); + const second = yield* CommandRuntime.pipe(Effect.provide(commandRuntimeLayer(["status"]))); expect(first.commandPath).toEqual(["status"]); expect(second.commandPath).toEqual(["status"]); expect(getCommandRuntimeCommand(first)).toBe("status"); expect(getCommandRuntimeSpanName(first)).toBe("command.status"); + expect(first.commandRunId).toMatch(UUID_V4); + expect(second.commandRunId).toMatch(UUID_V4); expect(first.commandRunId).not.toBe(second.commandRunId); - }), + }).pipe(Effect.provide(BunCrypto.layer)), ); }); diff --git a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts index 452ba363ad..475fd170d4 100644 --- a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts @@ -95,7 +95,7 @@ describe("ProcessControl", () => { Effect.gen(function* () { yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); yield* Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)); - yield* Effect.never; + return yield* Effect.never; }), ).pipe(Effect.forkChild({ startImmediately: true })); 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 691615937a..db726d37e9 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 @@ -1,7 +1,6 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it, vi } from "@effect/vitest"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { createStackE2eCleanupManager } from "../../../tests/helpers/stack-e2e-cleanup.ts"; import { CliHomeDisposeError } from "../../../tests/helpers/cli.ts"; @@ -9,14 +8,17 @@ function permissionError(message = "permission denied") { return Object.assign(new Error(message), { code: "EACCES" }); } +const drain = (manager: ReturnType) => + Effect.promise(() => manager.drain()); + function cleanupEnvironment( calls: Array, overrides: Partial[0]> = {}, ): Parameters[0] { return { - stopStack: async (projectDir, homeDir) => { + stopStack: (projectDir, homeDir) => { calls.push(`stop:${projectDir}:${homeDir}`); - return { exitCode: 0 }; + return Promise.resolve({ exitCode: 0 }); }, captureSnapshot: () => ({ managedStacksRootExists: false, @@ -24,13 +26,14 @@ function cleanupEnvironment( stackDirs: [], trackedPids: [], }), - waitForCleanup: async () => true, - forceCleanup: async () => { + waitForCleanup: () => Promise.resolve(true), + forceCleanup: () => { calls.push("force"); + return Promise.resolve(); }, - removeProjectWithDocker: async () => { + removeProjectWithDocker: () => { calls.push("docker-remove"); - return false; + return Promise.resolve(false); }, repairProjectPermissions: () => { calls.push("chmod"); @@ -41,79 +44,82 @@ function cleanupEnvironment( } describe("stack e2e cleanup manager", () => { - it("cleans a registered stack project and associated home once", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: ["/tmp/stack.json"], - stackDirs: ["/tmp/stack"], - trackedPids: [], + it.effect("cleans a registered stack project and associated home once", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: ["/tmp/stack.json"], + stackDirs: ["/tmp/stack"], + trackedPids: [], + }), }), - }), - ); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + ); - await manager.drain(); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); - }); + yield* drain(manager); - 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"); - const projectLink = join(root, "project-link"); - const home = join(root, "home"); - const homeLink = join(root, "home-link"); - mkdirSync(project); - mkdirSync(home); - symlinkSync(project, projectLink); - symlinkSync(home, homeLink); - - const snapshots: Array<{ readonly projectDir: string; readonly homeDir?: string }> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment([], { - captureSnapshot: (projectDir, homeDir) => { - snapshots.push({ projectDir, homeDir }); - return { - managedStacksRootExists: false, - documentFiles: [], - stackDirs: [], - trackedPids: [], - }; - }, - }), - ); + expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); + }), + ); + + it.effect("canonicalizes symlinked project and home paths before matching stack state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-e2e-cleanup-" }); + const project = path.join(root, "project"); + const projectLink = path.join(root, "project-link"); + const home = path.join(root, "home"); + const homeLink = path.join(root, "home-link"); + yield* fs.makeDirectory(project); + yield* fs.makeDirectory(home); + yield* fs.symlink(project, projectLink); + yield* fs.symlink(home, homeLink); + + const snapshots: Array<{ readonly projectDir: string; readonly homeDir?: string }> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment([], { + captureSnapshot: (projectDir, homeDir) => { + snapshots.push({ projectDir, homeDir }); + return { + managedStacksRootExists: false, + documentFiles: [], + stackDirs: [], + trackedPids: [], + }; + }, + }), + ); - try { manager.registerHome({ dir: homeLink, dispose: () => {} }); - manager.registerStackProject({ dir: projectLink, cleanup: async () => {} }); + manager.registerStackProject({ dir: projectLink, cleanup: () => Promise.resolve() }); manager.associateHome(projectLink, homeLink); - await manager.drain(); + yield* drain(manager); expect(snapshots).toEqual([ - { projectDir: realpathSync(project), homeDir: realpathSync(home) }, + { projectDir: yield* fs.realPath(project), homeDir: yield* fs.realPath(home) }, ]); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("preserves project and home cleanup receivers", async () => { + it.effect("preserves project and home cleanup receivers", () => { class ReceiverHome { readonly dir = "/tmp/home"; disposed = false; @@ -127,304 +133,332 @@ describe("stack e2e cleanup manager", () => { readonly dir = "/tmp/project"; cleaned = false; - async cleanup() { + cleanup() { this.cleaned = true; + return Promise.resolve(); } } - const home = new ReceiverHome(); - const project = new ReceiverProject(); - const manager = createStackE2eCleanupManager(cleanupEnvironment([])); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { + return Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const home = new ReceiverHome(); + const project = new ReceiverProject(); + const manager = createStackE2eCleanupManager(cleanupEnvironment([])); manager.registerHome(home); manager.registerStackProject(project); manager.associateHome(project.dir, home.dir); - await manager.drain(); + yield* drain(manager); 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)); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); + it.effect("ignores non-stack homes", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - await manager.drain(); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); - expect(calls).toEqual([]); - }); + yield* drain(manager); - it("warns when graceful cleanup leaves leaked resources behind", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - stopStack: async () => { - calls.push("stop"); - return { exitCode: 0 }; - }, - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: ["/tmp/stack.json"], - stackDirs: ["/tmp/stack"], - trackedPids: [123], + expect(calls).toEqual([]); + }), + ); + + it.effect("warns when graceful cleanup leaves leaked resources behind", () => + Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + stopStack: () => { + calls.push("stop"); + return Promise.resolve({ exitCode: 0 }); + }, + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: ["/tmp/stack.json"], + stackDirs: ["/tmp/stack"], + trackedPids: [123], + }), + waitForCleanup: () => Promise.resolve(false), }), - waitForCleanup: async () => false, - }), - ); + ); - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); + expect(yield* drain(manager)).toBeUndefined(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("leaked stack resources")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual(["stop", "force", "cleanup-project", "dispose-home"]); - }); - - it("stops persisted stack directories even when no live runtime artifacts remain", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: [], - stackDirs: ["/tmp/home/stacks/stack-id"], - trackedPids: [], + expect(calls).toEqual(["stop", "force", "cleanup-project", "dispose-home"]); + }), + ); + + it.effect("stops persisted stack directories even when no live runtime artifacts remain", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: [], + stackDirs: ["/tmp/home/stacks/stack-id"], + trackedPids: [], + }), }), - }), - ); + ); - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - await manager.drain(); + yield* drain(manager); - expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); - }); + expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); + }), + ); + + it.effect("removes permission-blocked projects with the Docker root fallback", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: () => { + calls.push("docker-remove"); + return Promise.resolve(true); + }, + }), + ); - it("removes permission-blocked projects with the Docker root fallback", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - removeProjectWithDocker: async () => { - calls.push("docker-remove"); - return true; + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.reject(permissionError()); }, - }), - ); - - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - throw permissionError(); - }, - }); + }); - await manager.drain(); + yield* drain(manager); - expect(calls).toEqual(["cleanup-project", "docker-remove"]); - }); + expect(calls).toEqual(["cleanup-project", "docker-remove"]); + }), + ); + + it.effect("classifies a permission errno wrapped in a typed error's cause chain", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: () => { + calls.push("docker-remove"); + return Promise.resolve(true); + }, + }), + ); - it("classifies a permission errno wrapped in a typed error's cause chain", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - removeProjectWithDocker: async () => { - calls.push("docker-remove"); - return true; + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.reject(new CliHomeDisposeError({ cause: permissionError() })); }, - }), - ); - - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - throw new CliHomeDisposeError({ cause: permissionError() }); - }, - }); + }); - await manager.drain(); + yield* drain(manager); - expect(calls).toEqual(["cleanup-project", "docker-remove"]); - }); + expect(calls).toEqual(["cleanup-project", "docker-remove"]); + }), + ); + + it.effect("removes permission-blocked associated homes with the Docker root fallback", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: () => { + calls.push("docker-remove"); + return Promise.resolve(true); + }, + }), + ); - 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.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"]); - }); + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - it("warns when an associated home remains after permission fallback", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + expect(yield* drain(manager)).toBeUndefined(); - 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"); + expect(calls).toEqual(["cleanup-project", "dispose-home", "docker-remove"]); + }), + ); + + it.effect("warns when an associated home remains after permission fallback", () => + Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + 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: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); + expect(yield* drain(manager)).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"]); - }); + expect(calls).toEqual([ + "cleanup-project", + "dispose-home", + "docker-remove", + "chmod", + "dispose-home", + ]); + }), + ); - it("falls back to chmod and retries cleanup when Docker cannot remove the project", async () => { - const calls: Array = []; - let attempts = 0; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - attempts += 1; - calls.push(`cleanup-project:${attempts}`); - if (attempts === 1) { - throw permissionError(); - } - }, - }); + it.effect("disposes an associated home once after all projects sharing it are cleaned", () => + Effect.gen(function* () { + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - await manager.drain(); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-one", + cleanup: () => { + calls.push("cleanup-project-one"); + return Promise.resolve(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-two", + cleanup: () => { + calls.push("cleanup-project-two"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project-one", "/tmp/home"); + manager.associateHome("/tmp/project-two", "/tmp/home"); - expect(calls).toEqual(["cleanup-project:1", "docker-remove", "chmod", "cleanup-project:2"]); - }); + yield* drain(manager); - it("warns with permission diagnostics when fallback cleanup still cannot remove the project", async () => { - const calls: Array = []; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + expect(calls).toEqual(["cleanup-project-one", "cleanup-project-two", "dispose-home"]); + }), + ); + + it.effect("falls back to chmod and retries cleanup when Docker cannot remove the project", () => + Effect.gen(function* () { + const calls: Array = []; + let attempts = 0; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + attempts += 1; + calls.push(`cleanup-project:${attempts}`); + if (attempts === 1) { + return Promise.reject(permissionError()); + } + return Promise.resolve(); + }, + }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - throw permissionError(); - }, - }); + yield* drain(manager); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("Permission diagnostics:")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual(["cleanup-project", "docker-remove", "chmod", "cleanup-project"]); - }); + expect(calls).toEqual(["cleanup-project:1", "docker-remove", "chmod", "cleanup-project:2"]); + }), + ); + + it.effect( + "warns with permission diagnostics when fallback cleanup still cannot remove the project", + () => + Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const calls: Array = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.reject(permissionError()); + }, + }); + + expect(yield* drain(manager)).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Permission diagnostics:")); + expect(calls).toEqual(["cleanup-project", "docker-remove", "chmod", "cleanup-project"]); + }), + ); }); diff --git a/apps/cli/src/shared/runtime/stdin.integration.test.ts b/apps/cli/src/shared/runtime/stdin.integration.test.ts index d72e072247..35f611b2fe 100644 --- a/apps/cli/src/shared/runtime/stdin.integration.test.ts +++ b/apps/cli/src/shared/runtime/stdin.integration.test.ts @@ -1,7 +1,9 @@ import { fileURLToPath } from "node:url"; +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Duration, Effect, Fiber, Layer, Option, Queue, Ref, Stream } from "effect"; +import { Cause, Duration, Effect, Fiber, Layer, Option, Queue, Ref, Stream } from "effect"; import { systemError, type PlatformError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { TestClock } from "effect/testing"; import { mockTty } from "../../../tests/helpers/mocks.ts"; @@ -270,27 +272,49 @@ describe("stdinLayer", () => { }); }); +const killOnTimeout = + (child: ChildProcessSpawner.ChildProcessHandle, stderr: Fiber.Fiber) => + (self: Effect.Effect) => + self.pipe( + Effect.timeout("20 seconds"), + Effect.catchTag("TimeoutError", () => + child.kill().pipe( + Effect.andThen(Fiber.join(stderr)), + Effect.flatMap((text) => Effect.die(new Error(`child timed out: ${text}`))), + ), + ), + ); + describe("stdinLayer over fd 0", () => { - it("waits out a non-blocking fd 0 until the answer lands", async () => { - // perl flips `O_NONBLOCK` on stdin (Bun cannot) and execs into the reader - // so fd 0 stays non-blocking. The first prompt must run its window out to - // None rather than treat the empty read as a dead descriptor; the second - // reads the answer once that window closes. - const bun = Bun.which("bun"); - const perl = Bun.which("perl"); - if (!bun || !perl) throw new Error("bun and perl executables not found"); - const here = (file: string) => JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); - const child = Bun.spawn( - [ - perl, - "-e", - `use Fcntl; + it.live( + "waits out a non-blocking fd 0 until the answer lands", + () => + Effect.gen(function* () { + // perl flips `O_NONBLOCK` on stdin (Bun cannot) and execs into the reader + // so fd 0 stays non-blocking. The first prompt must run its window out to + // None rather than treat the empty read as a dead descriptor; the second + // reads the answer once that window closes. + const bun = Bun.which("bun"); + const perl = Bun.which("perl"); + if (!bun || !perl) { + return yield* Effect.die(new Error("bun and perl executables not found")); + } + const here = (file: string) => + JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); + const input = yield* Queue.unbounded(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + perl, + [ + "-e", + `use Fcntl; fcntl(STDIN, F_SETFL, O_NONBLOCK) or die "fcntl: $!"; print STDERR ((fcntl(STDIN, F_GETFL, 0) & O_NONBLOCK) ? "nonblock\\n" : "block\\n"); exec @ARGV or die "exec: $!";`, - bun, - "-e", - `import { Effect, Layer, Option } from "effect"; + bun, + "-e", + `import { Effect, Layer, Option } from "effect"; import { Stdin } from ${here("./stdin.service.ts")}; import { stdinLayer } from ${here("./stdin.layer.ts")}; import { ttyLayer } from ${here("./tty.layer.ts")}; @@ -302,52 +326,70 @@ describe("stdinLayer over fd 0", () => { Effect.runPromise(program.pipe(Effect.provide(stdinLayer.pipe(Layer.provide(ttyLayer))))).then( () => process.exit(0), );`, - ], - { cwd: import.meta.dirname, stdin: "pipe", stdout: "pipe", stderr: "pipe", timeout: 20_000 }, - ); - const stdout = child.stdout.pipeThrough(new TextDecoderStream()).getReader(); - let buffered = ""; - const nextLine = async () => { - while (!buffered.includes("\n")) { - const { value, done } = await stdout.read(); - if (done) throw new Error(`child exited early: ${await new Response(child.stderr).text()}`); - buffered += value; - } - const [line, ...rest] = buffered.split("\n"); - buffered = rest.join("\n"); - return line; - }; - try { - expect(await nextLine()).toBe(""); - await child.stdin.write("y\n"); - await child.stdin.flush(); - expect(await nextLine()).toBe("y"); - await child.stdin.end(); - const [exitCode, stderr] = await Promise.all([ - child.exited, - new Response(child.stderr).text(), - ]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toContain("nonblock"); - } finally { - // A failed assertion must not leave the child waiting on its second prompt. - child.kill(); - } - }, 30_000); + ], + { + cwd: import.meta.dirname, + stdin: Stream.fromQueue(input), + stdout: "pipe", + stderr: "pipe", + }, + ), + ); + const lines = yield* Stream.toQueue(Stream.splitLines(Stream.decodeText(child.stdout)), { + capacity: "unbounded", + }); + const stderrFiber = yield* Effect.forkChild( + Stream.mkString(Stream.decodeText(child.stderr)), + ); + const nextLine = Queue.take(lines).pipe( + Effect.catchTag("Done", () => + Fiber.join(stderrFiber).pipe( + Effect.flatMap((stderr) => Effect.die(new Error(`child exited early: ${stderr}`))), + ), + ), + ); + yield* Effect.gen(function* () { + expect(yield* nextLine).toBe(""); + yield* Queue.offer(input, enc("y\n")); + expect(yield* nextLine).toBe("y"); + yield* Queue.end(input); + const [exitCode, stderr] = yield* Effect.all([child.exitCode, Fiber.join(stderrFiber)], { + concurrency: "unbounded", + }); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("nonblock"); + }).pipe(killOnTimeout(child, stderrFiber)); + }).pipe( + // A failed assertion must not leave the child waiting on its second prompt. + Effect.scoped, + Effect.provide(BunServices.layer), + ), + 30_000, + ); - it("answers prompts from a flooded pipe and leaves the rest for a child inheriting fd 0", async () => { - // 2 MiB of lines piped in; three prompts take the first three, then a - // child inheriting fd 0 counts what's left. A reader that fully drained - // stdin would leave it nothing. - const bun = Bun.which("bun"); - if (!bun) throw new Error("Bun executable not found"); - const here = (file: string) => JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); - const payload = enc(Array.from({ length: 200_000 }, (_, index) => `line-${index}\n`).join("")); - const child = Bun.spawn( - [ - bun, - "-e", - `import { Effect, Layer, Option } from "effect"; + it.live( + "answers prompts from a flooded pipe and leaves the rest for a child inheriting fd 0", + () => + Effect.gen(function* () { + // 2 MiB of lines piped in; three prompts take the first three, then a + // child inheriting fd 0 counts what's left. A reader that fully drained + // stdin would leave it nothing. + const bun = Bun.which("bun"); + if (!bun) { + return yield* Effect.die(new Error("Bun executable not found")); + } + const here = (file: string) => + JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); + const payload = enc( + Array.from({ length: 200_000 }, (_, index) => `line-${index}\n`).join(""), + ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + bun, + [ + "-e", + `import { Effect, Layer, Option } from "effect"; import { Stdin } from ${here("./stdin.service.ts")}; import { stdinLayer } from ${here("./stdin.layer.ts")}; import { ttyLayer } from ${here("./tty.layer.ts")}; @@ -367,19 +409,33 @@ describe("stdinLayer over fd 0", () => { Effect.runPromise(program.pipe(Effect.provide(stdinLayer.pipe(Layer.provide(ttyLayer))))).then( () => process.exit(0), );`, - ], - // Prompts give up after 3 x 5 s; a child that hangs anyway is killed at 20 s, ahead of - // vitest's 30 s guard, so the failure still carries its stderr. - { cwd: import.meta.dirname, stdin: payload, stdout: "pipe", stderr: "pipe", timeout: 20_000 }, - ); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - expect(exitCode, stderr).toBe(0); - const [answers, left] = stdout.trim().split("\n"); - expect(answers).toBe("line-0 line-1 line-2"); - expect(payload.length - Number(left)).toBeLessThanOrEqual(256 * 1024); - }, 30_000); + ], + { + cwd: import.meta.dirname, + stdin: Stream.make(payload), + stdout: "pipe", + stderr: "pipe", + }, + ), + ); + const stderrFiber = yield* Effect.forkChild( + Stream.mkString(Stream.decodeText(child.stderr)), + ); + // Prompts give up after 3 x 5 s; a child that hangs anyway is killed at 20 s, ahead of + // vitest's 30 s guard, so the failure still carries its stderr. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode, + Stream.mkString(Stream.decodeText(child.stdout)), + Fiber.join(stderrFiber), + ], + { concurrency: "unbounded" }, + ).pipe(killOnTimeout(child, stderrFiber)); + expect(exitCode, stderr).toBe(0); + const [answers, left] = stdout.trim().split("\n"); + expect(answers).toBe("line-0 line-1 line-2"); + expect(payload.length - Number(left)).toBeLessThanOrEqual(256 * 1024); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + 30_000, + ); }); diff --git a/apps/cli/src/shared/runtime/stdin.layer.ts b/apps/cli/src/shared/runtime/stdin.layer.ts index 9371deb579..b08f84d98b 100644 --- a/apps/cli/src/shared/runtime/stdin.layer.ts +++ b/apps/cli/src/shared/runtime/stdin.layer.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- no effect or platform-bun stream reads fd 0 with backpressure and non-blocking EAGAIN handling (#6287). import { createReadStream } from "node:fs"; import { BunStream } from "@effect/platform-bun"; import { @@ -84,19 +85,19 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream Option.none()), - ); - }); + Effect.map((pull) => + pull.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ), + ), + ); // Persistent, lazily-opened (via `Effect.cached`) line reader shared by every `readLine` // call, so successive prompts read successive piped lines instead of restarting the pipe. diff --git a/apps/cli/src/shared/runtime/tty.layer.ts b/apps/cli/src/shared/runtime/tty.layer.ts index f174015e70..febaca7061 100644 --- a/apps/cli/src/shared/runtime/tty.layer.ts +++ b/apps/cli/src/shared/runtime/tty.layer.ts @@ -1,19 +1,19 @@ -import { fstatSync } from "node:fs"; import process from "node:process"; -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { Tty } from "./tty.service.ts"; -export const ttyLayer = Layer.sync(Tty, () => - Tty.of({ - stdinIsTty: !!process.stdin.isTTY, - stdoutIsTty: !!process.stdout.isTTY, - stdoutIsPipe: (() => { - try { - return fstatSync(1).isFIFO(); - } catch { - return false; - } - })(), - }), +export const ttyLayer = Layer.effect( + Tty, + Effect.tryPromise(() => Bun.file(1).stat()).pipe( + Effect.map((stats) => stats.isFIFO()), + Effect.orElseSucceed(() => false), + Effect.map((stdoutIsPipe) => + Tty.of({ + stdinIsTty: !!process.stdin.isTTY, + stdoutIsTty: !!process.stdout.isTTY, + stdoutIsPipe, + }), + ), + ), ); diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts index 6f8a3f26fe..7434fb22ea 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Cause, Data, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "./analytics-context.ts"; @@ -114,7 +115,7 @@ describe("withCommandInstrumentation", () => { analytics.layer, mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), - commandRuntimeLayer(["branches", "list"]), + commandRuntimeLayer(["branches", "list"]).pipe(Layer.provide(BunCrypto.layer)), ), ), ); @@ -137,7 +138,7 @@ describe("withCommandInstrumentation", () => { analytics.layer, mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["start", "--detach", "--exclude=auth"]) }), - commandRuntimeLayer(["start"]), + commandRuntimeLayer(["start"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.tap(() => @@ -181,7 +182,7 @@ describe("withCommandInstrumentation", () => { analytics.layer, mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["login"]) }), - commandRuntimeLayer(["login"]), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.exit, @@ -225,7 +226,7 @@ describe("withCommandInstrumentation", () => { analytics.layer, mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), - commandRuntimeLayer(["branches", "list"]), + commandRuntimeLayer(["branches", "list"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.exit, @@ -263,7 +264,7 @@ describe("withCommandInstrumentation", () => { failingAnalytics(new Error("telemetry defect")), mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["login"]) }), - commandRuntimeLayer(["login"]), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.exit, @@ -291,7 +292,7 @@ describe("withCommandInstrumentation", () => { interruptingAnalytics(), mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["login"]) }), - commandRuntimeLayer(["login"]), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.exit, @@ -336,7 +337,7 @@ describe("withCommandInstrumentation", () => { "storage", ]), }), - commandRuntimeLayer(["start"]), + commandRuntimeLayer(["start"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.tap(() => @@ -375,7 +376,7 @@ describe("withCommandInstrumentation", () => { Stdio.layerTest({ args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), }), - commandRuntimeLayer(["login"]), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.tap(() => @@ -401,7 +402,7 @@ describe("withCommandInstrumentation", () => { analytics.layer, mockOutput({ format: "text" }).layer, Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) }), - commandRuntimeLayer(["telemetry", "enable"]), + commandRuntimeLayer(["telemetry", "enable"]).pipe(Layer.provide(BunCrypto.layer)), ), ), Effect.tap(() => diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index a8b950b96a..6e8b8598a2 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; import { Flag } from "effect/unstable/cli"; import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; @@ -99,7 +100,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -130,7 +131,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--output", "yaml"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("yaml"); @@ -159,7 +160,7 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("json"); @@ -183,7 +184,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["secrets", "list", "--project-ref", "abcdefghijklmnopqrst"]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"])), + Effect.provide(commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -211,7 +212,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["secrets", "set", "--env-file=/path/to/.env"]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"])), + Effect.provide(commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -234,7 +235,7 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--password", "super-secret"]) }), ), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -260,7 +261,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "-s", "public"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -292,7 +293,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["stack", "logs", "-f", "--service", "database"]), }), ), - Effect.provide(commandRuntimeLayer(["stack", "logs"])), + Effect.provide(commandRuntimeLayer(["stack", "logs"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -319,7 +320,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "dump", "-x", "public.users", "-f", "out.sql"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -345,7 +346,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "query", "-f", "query.sql"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "query"])), + Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -380,7 +381,11 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["db", "schema", "declarative", "generate"])), + Effect.provide( + commandRuntimeLayer(["db", "schema", "declarative", "generate"]).pipe( + Layer.provide(BunCrypto.layer), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -415,7 +420,11 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["db", "schema", "declarative", "sync"])), + Effect.provide( + commandRuntimeLayer(["db", "schema", "declarative", "sync"]).pipe( + Layer.provide(BunCrypto.layer), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -448,7 +457,9 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["ssl-enforcement", "update"])), + Effect.provide( + commandRuntimeLayer(["ssl-enforcement", "update"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -477,7 +488,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["link", "--project-ref", "abcdefghijklmnopqrst"]), }), ), - Effect.provide(commandRuntimeLayer(["link"])), + Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -506,7 +517,7 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["gen", "types", "--lang", "python"]) }), ), - Effect.provide(commandRuntimeLayer(["gen", "types"])), + Effect.provide(commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -538,7 +549,9 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "RS256"]) }), ), - Effect.provide(commandRuntimeLayer(["gen", "signing-key"])), + Effect.provide( + commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -567,7 +580,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "-t", "saml"]) })), - Effect.provide(commandRuntimeLayer(["sso", "add"])), + Effect.provide(commandRuntimeLayer(["sso", "add"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -595,7 +608,9 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "ES256"]) }), ), - Effect.provide(commandRuntimeLayer(["gen", "signing-key"])), + Effect.provide( + commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -625,7 +640,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["gen", "types", "--lang", "go", "--schema", "public"]), }), ), - Effect.provide(commandRuntimeLayer(["gen", "types"])), + Effect.provide(commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -644,7 +659,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -663,7 +678,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -694,7 +709,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -718,7 +733,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -745,7 +760,7 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "lint"]) })), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -775,7 +790,7 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) })), - Effect.provide(commandRuntimeLayer(["db", "advisors"])), + Effect.provide(commandRuntimeLayer(["db", "advisors"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -810,7 +825,7 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -838,7 +853,9 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) })), - Effect.provide(commandRuntimeLayer(["unknown", "command"])), + Effect.provide( + commandRuntimeLayer(["unknown", "command"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -871,7 +888,9 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--exit-code"]) }), ), - Effect.provide(commandRuntimeLayer(["config", "diff"])), + Effect.provide( + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -904,7 +923,9 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -932,7 +953,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["config", "diff"]) })), - Effect.provide(commandRuntimeLayer(["config", "diff"])), + Effect.provide(commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -959,7 +980,9 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) })), - Effect.provide(commandRuntimeLayer(["telemetry", "enable"])), + Effect.provide( + commandRuntimeLayer(["telemetry", "enable"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toEqual([]); @@ -992,7 +1015,9 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "restore"])), + Effect.provide( + commandRuntimeLayer(["backups", "restore"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1012,7 +1037,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(mockProcessControl().layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "table"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), // `table` is valid on the shared global union but not for a resource command. Effect.provide(Layer.succeed(OutputFlag, Option.some("table" as const))), Effect.flip, @@ -1037,7 +1062,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(mockProcessControl().layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "csv"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"])), + Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(OutputFlag, Option.some("csv" as const))), Effect.tap(() => Effect.sync(() => { @@ -1058,7 +1083,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"])), + Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(stitch.layer), Effect.tap(() => Effect.sync(() => { @@ -1078,7 +1103,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(mockProcessControl().layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "yaml"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"])), + Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(OutputFlag, Option.some("yaml" as const))), Effect.flip, Effect.tap((error) => @@ -1101,7 +1126,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"])), + Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(stitch.layer), Effect.tap(() => Effect.sync(() => { @@ -1123,7 +1148,9 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1150,7 +1177,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "--schema", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1177,7 +1204,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "--schema=public", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1203,7 +1230,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "-s", "public", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1229,7 +1256,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "--db-url", "x", "--local"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1249,7 +1276,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(DebugFlag, true)), Effect.tap(() => Effect.sync(() => { @@ -1281,7 +1308,7 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"])), + Effect.provide(commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(DebugFlag, true)), Effect.tap(() => Effect.sync(() => { @@ -1310,7 +1337,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.provide(Layer.succeed(WorkdirFlag, Option.some("/tmp/project"))), Effect.tap(() => Effect.sync(() => { @@ -1337,7 +1366,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.provide(Layer.succeed(DnsResolverFlag, "https" as const)), Effect.tap(() => Effect.sync(() => { @@ -1364,7 +1395,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--agent", "yes"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.provide(Layer.succeed(AgentFlag, "yes" as const)), Effect.tap(() => Effect.sync(() => { @@ -1395,7 +1428,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "diff"])), + Effect.provide(commandRuntimeLayer(["db", "diff"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1415,7 +1448,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1438,7 +1471,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["test", "db", "--", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["test", "db"])), + Effect.provide(commandRuntimeLayer(["test", "db"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags; @@ -1457,7 +1490,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(OutputFlag, Option.some("json" as const))), Effect.tap(() => Effect.sync(() => { @@ -1489,7 +1522,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"])), + Effect.provide( + commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index a3380a4980..27b5d33b2c 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -286,7 +286,7 @@ export function setupNotebooks(options: NotebooksSetupOptions) { cache.layer, analytics.layer, process.layer, - commandRuntimeLayer(["notebooks", command]), + commandRuntimeLayer(["notebooks", command]).pipe(Layer.provide(BunServices.layer)), Layer.succeed( OutputFlag, options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), From e49b7913be8b4b62ba8cefc86ce59c76c02b0678 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 13:26:27 +0000 Subject: [PATCH 40/71] fix(stack): keep in-use cached artifacts when a request needs more paths (#6882) ## Problem When a request's required runtime paths weren't all recorded in a cached artifact's metadata, the store treated the entry as a miss. It then renamed the published tree aside, deleted it, and downloaded the artifact again. That happens when an artifact definition gains a required path, such as `bin/psql` or `bin/pg_prove`, while an older cache entry exists. A key identifies immutable content, so the tree was valid, and another process could be running a workload from it when it was deleted. ## Change A cached entry is a miss only when the key or executable path differs. Required paths the metadata hasn't recorded are checked against the published tree with the same structural checks used at publish time. Once they pass, they're added to the metadata through an atomic write, and the entry is served from the cache. A path that is genuinely missing is still an integrity error. --- .../stack/src/preparation/ArtifactStore.ts | 117 +++++++++---- .../preparation/artifacts.integration.test.ts | 162 ++++++++++++++---- 2 files changed, 214 insertions(+), 65 deletions(-) diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 2b20d8c05e..1bbaf34755 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -511,31 +511,22 @@ const ensureExecutableFile = ( ); }; -const metadataMatchesRequest = (request: ArtifactRequest, metadata: ArtifactMetadata): boolean => { - const samePaths = - metadata.requiredRuntimePaths.length === request.requiredRuntimePaths.length && - metadata.requiredRuntimePaths.every( - (entry, index) => entry === request.requiredRuntimePaths[index], - ); - const kindEntries = Object.keys(metadata.requiredRuntimeKinds); - const sameKinds = - kindEntries.length === request.requiredRuntimePaths.length && - request.requiredRuntimePaths.every( - (entry) => metadata.requiredRuntimeKinds[entry] !== undefined, - ); - return ( - metadata.key === request.key && - samePaths && - sameKinds && - metadata.executablePath === request.executablePath +const identityMismatch = (request: ArtifactRequest, metadata: ArtifactMetadata): boolean => + metadata.key !== request.key || metadata.executablePath !== request.executablePath; + +const unrecordedRequiredPaths = ( + request: ArtifactRequest, + metadata: ArtifactMetadata, +): ReadonlyArray => + request.requiredRuntimePaths.filter( + (relative) => metadata.requiredRuntimeKinds[relative] === undefined, ); -}; -const verifyMetadata = ( +const sha256Of = ( request: ArtifactRequest, metadata: ArtifactMetadata, -): Effect.Effect => { - const sha256 = validateSha256(metadata.sha256).pipe( +): Effect.Effect => + validateSha256(metadata.sha256).pipe( Effect.mapError((cause) => metadataError("Cached artifact metadata contains an invalid SHA-256", { key: request.key, @@ -543,12 +534,6 @@ const verifyMetadata = ( }), ), ); - if (!metadataMatchesRequest(request, metadata)) - return Effect.fail( - metadataError("Cached artifact metadata does not match the request", { key: request.key }), - ); - return sha256; -}; const writeBytesSync = ( fs: FileSystem.FileSystem, @@ -600,6 +585,33 @@ const cleanup = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + Effect.gen(function* () { + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + artifactError(`Unable to allocate artifact metadata temporary name: ${cause.message}`, { + path: metadataPath, + cause, + }), + ), + ); + const temporary = path.join(path.dirname(metadataPath), `${METADATA_NAME}.${token}.tmp`); + yield* Effect.gen(function* () { + yield* writeMetadataSync(fs, temporary, metadata); + yield* mapFs( + metadataPath, + "update cached artifact metadata", + fs.rename(temporary, metadataPath), + ); + }).pipe(Effect.onExit(() => cleanup(fs, temporary))); + }); + const orphanMaxAgeMillis = 24 * 60 * 60 * 1000; const leftoverToken = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; @@ -669,7 +681,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const metadataPath = path.join(target, METADATA_NAME); const checkCached: Effect.Effect< Option.Option, - ArtifactIntegrityError + ArtifactStoreError > = Effect.gen(function* () { const realRoot = yield* ensureSafeRoot(fs, path, target, cacheRoot).pipe( Effect.map(Option.some), @@ -681,20 +693,59 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const cachedMetadata = yield* readMetadata(fs, metadataPath); if (Option.isNone(cachedMetadata)) return Option.none(); const metadata = cachedMetadata.value; - // Same version/target key with an expanded required-path list is a miss, not corruption. - if (!metadataMatchesRequest(request, metadata)) return Option.none(); - const sha256 = yield* verifyMetadata(request, metadata); + // A key names immutable content and sources unpack whole archives, so only a different key + // or executable is a miss; paths beyond the recorded ones are checked in the published tree. + if (identityMismatch(request, metadata)) return Option.none(); + const sha256 = yield* sha256Of(request, metadata); + const newPaths = unrecordedRequiredPaths(request, metadata); + const newPathSet = new Set(newPaths); + const recordedPaths = request.requiredRuntimePaths.filter( + (relative) => !newPathSet.has(relative), + ); // Published content is not rehashed on cache hits: metadata and cheap structural checks // protect the cache boundary; content tampering may execute or fail later when the // workload starts. - const safePaths = yield* ensureSafePaths( + const recordedSafePaths = yield* ensureSafePaths( fs, path, target, realRoot.value, metadata, - request.requiredRuntimePaths, + recordedPaths, ); + // A path the recorded metadata has not seen yet gets the same containment and safety + // validation a fresh publish applies, before its kind is trusted and recorded. + const newSafePaths = + newPaths.length > 0 + ? yield* validateFreshRuntimePaths(fs, path, target, realRoot.value, newPaths).pipe( + Effect.mapError((cause) => + metadataError( + `Cached artifact ${request.key} cannot serve newly required runtime paths (${cause.message}); remove ${target} to download it again`, + { key: request.key, path: target, cause }, + ), + ), + ) + : {}; + const safePaths = { ...recordedSafePaths, ...newSafePaths }; + if (newPaths.length > 0) { + const newKinds = Object.fromEntries( + Object.entries(newSafePaths).map(([relative, inspected]) => [relative, inspected.kind]), + ); + const appendedPaths = newPaths.filter( + (relative) => !metadata.requiredRuntimePaths.includes(relative), + ); + // The paths above are already validated; a failed write only loses the recording, so + // it must not fail preparation. A later request re-validates and retries the write. + yield* updateMetadataAtomic(fs, path, crypto, metadataPath, { + ...metadata, + requiredRuntimePaths: [...metadata.requiredRuntimePaths, ...appendedPaths], + requiredRuntimeKinds: { ...metadata.requiredRuntimeKinds, ...newKinds }, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Unable to record newly validated artifact runtime paths", cause), + ), + ); + } if (request.executablePath !== undefined) { const executable = safePaths[request.executablePath]; if (executable === undefined) diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 628d430bf4..8f637d0a42 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -12,6 +12,7 @@ import { Layer, Option, PlatformError, + Schema, } from "effect"; import { ArtifactIntegrityError, PreparationError } from "./Errors.ts"; import { makeArtifactStore, type ArtifactRequest, type ArtifactSource } from "./ArtifactStore.ts"; @@ -147,38 +148,57 @@ describe("verified native artifact preparation", () => { ), ); - it.live("re-downloads when required runtime paths expand on the same key", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-artifact-paths-expand-", - }); - let checksumCalls = 0; - let materializeCalls = 0; - const source: ArtifactSource = { - checksum: () => - Effect.sync(() => { - checksumCalls += 1; - return archiveSha256; - }), - materialize: (entry, destination) => - Effect.sync(() => { - materializeCalls += 1; - return entry; - }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256))), - }; - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const narrow: ArtifactRequest = { ...request, requiredRuntimePaths: ["bin/postgres"] }; - const first = yield* store.prepare(narrow); - const second = yield* store.prepare(request); - expect(first.outcome).toBe("downloaded"); - expect(second.outcome).toBe("downloaded"); - expect(second.requiredRuntimePaths).toEqual([...request.requiredRuntimePaths]); - expect(checksumCalls).toBe(2); - expect(materializeCalls).toBe(2); - }), - ), + it.live( + "keeps the published tree and reuses it when required runtime paths expand on the same key", + () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-paths-expand-", + }); + let checksumCalls = 0; + let materializeCalls = 0; + const source: ArtifactSource = { + checksum: () => + Effect.sync(() => { + checksumCalls += 1; + return archiveSha256; + }), + materialize: (entry, destination) => + Effect.sync(() => { + materializeCalls += 1; + return entry; + }).pipe( + Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256)), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const narrow: ArtifactRequest = { ...request, requiredRuntimePaths: ["bin/postgres"] }; + const first = yield* store.prepare(narrow); + const publishedIno = (yield* fs.stat(first.path)).ino; + const second = yield* store.prepare(request); + const stillPublishedIno = (yield* fs.stat(second.path)).ino; + expect(first.outcome).toBe("downloaded"); + expect(second.outcome).toBe("cached"); + expect(second.path).toBe(first.path); + expect(stillPublishedIno).toEqual(publishedIno); + expect(second.requiredRuntimePaths).toEqual([...request.requiredRuntimePaths]); + expect(checksumCalls).toBe(1); + expect(materializeCalls).toBe(1); + expect(yield* fs.readFileString(`${second.path}/bin/postgres`)).toBe("native postgres"); + const metadata = yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + requiredRuntimePaths: Schema.Array(Schema.String), + requiredRuntimeKinds: Schema.Record(Schema.String, Schema.String), + }), + ), + )(yield* fs.readFileString(`${second.path}/.artifact.json`)); + expect(metadata.requiredRuntimePaths).toContain("etc/postgres.conf"); + expect(metadata.requiredRuntimeKinds["etc/postgres.conf"]).toBe("file"); + }), + ), ); it.live("replaces a cached tree with unknown artifact metadata", () => @@ -363,6 +383,33 @@ describe("verified native artifact preparation", () => { ), ); + it.live("rejects a cache hit whose newly required path is missing from the published tree", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-new-path-missing-", + }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const narrow: ArtifactRequest = { ...request, requiredRuntimePaths: ["bin/postgres"] }; + const published = yield* store.prepare(narrow); + const publishedIno = (yield* fs.stat(published.path)).ino; + + const wider: ArtifactRequest = { + ...request, + requiredRuntimePaths: ["bin/postgres", "share/missing"], + }; + const exit = yield* store.prepare(wider).pipe(Effect.exit); + + const error = errorOf(exit); + expect(error).toBeInstanceOf(ArtifactIntegrityError); + expect(error?.message).toContain(`remove ${published.path} to download it again`); + expect(yield* fs.exists(published.path)).toBe(true); + expect((yield* fs.stat(published.path)).ino).toEqual(publishedIno); + }), + ), + ); + it.live("rejects a required path whose basic kind changes on a cache hit", () => withPlatform( Effect.gen(function* () { @@ -609,6 +656,57 @@ describe("verified native artifact preparation", () => { ), ); + it.live("rejects an escaping symlink nested in a newly required directory on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-new-nested-link-escape-", + }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-new-nested-link-outside-", + }); + const outsideConfig = `${outside}/config`; + yield* fs.writeFileString(outsideConfig, "outside"); + const directoryRequest: ArtifactRequest = { + ...request, + key: "database/postgres-cache-new-nested-link", + requiredRuntimePaths: ["bin/postgres", "share/runtime"], + }; + const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/share/runtime`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.symlink(outsideConfig, `${destination}/share/runtime/config`); + return; + }).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const narrow: ArtifactRequest = { + ...directoryRequest, + requiredRuntimePaths: ["bin/postgres"], + }; + const published = yield* store.prepare(narrow); + + const exit = yield* store.prepare(directoryRequest).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + expect(yield* fs.exists(published.path)).toBe(true); + }), + ), + ); + it.live("cancels caller-owned preparation when the caller is interrupted", () => withPlatform( Effect.gen(function* () { From 72b10f88c639b43f0f1937416c6d1b3adb54bb6b Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:32:40 +0000 Subject: [PATCH 41/71] fix(cli): keep migra session settings on reconnect (CLI-2522) (#6884) ## TL;DR stops migra `db diff` and `db pull` printing spurious drops and revoke floods for identical schemas. ## whats broken? the migra script ran `set role postgres` and `set search_path` once per connection pool. when `pg-pool` reopened a connection after its 10s idle timeout, or after the server dropped it, the new connection kept the server defaults. that side then deparsed definitions without the `public.` prefix or read grants as the login role, so migra printed drop and create pairs or thousands of revokes. ## now fixed by: running both statements from a `pg-pool` verify hook, so every new connection gets them before its first query. a failing step down still ends in the script error path, and the error now names the statement. ## ref: - closes: https://github.com/supabase/cli/issues/6860 - missed in: https://github.com/supabase/cli/pull/4349 & https://github.com/supabase/cli/pull/4353 --- .../internal/db/diff/templates/migra.ts | 40 ++- .../db/shared/migra.deno-templates.ts | 8 +- .../shared/migra.deno-templates.unit.test.ts | 228 +++++++++++++++++- 3 files changed, 263 insertions(+), 13 deletions(-) diff --git a/apps/cli-go/internal/db/diff/templates/migra.ts b/apps/cli-go/internal/db/diff/templates/migra.ts index 11884e9c59..d67124140f 100644 --- a/apps/cli-go/internal/db/diff/templates/migra.ts +++ b/apps/cli-go/internal/db/diff/templates/migra.ts @@ -1,4 +1,4 @@ -import { createClient, sql } from "npm:@pgkit/client"; +import { createClient } from "npm:@pgkit/client"; import { Migration } from "npm:@pgkit/migra"; // Avoids error on self-signed certificate @@ -30,9 +30,38 @@ if (sslDebug) { ); } -const clientBase = createClient(source); +type PoolClient = { + query: (stmt: string) => Promise; + on: (event: "error", listener: () => void) => void; + off: (event: "error", listener: () => void) => void; +}; +// Step down from login role to postgres and force schema qualified references for +// pg_get_expr on every pooled connection, including one reopened after an idle timeout +const verify = (stmt: string) => (client: PoolClient, done: (err?: Error) => void) => { + // pg-pool drops its error listener during verify, so a socket error must reject, not throw + const ignore = () => {}; + client.on("error", ignore); + client + .query(stmt) + .finally(() => client.off("error", ignore)) + .then( + () => done(), + (err: Error) => { + err.message = `${stmt}: ${err.message}`; + done(err); + }, + ); +}; +const clientBase = createClient(source, { + pgpOptions: { connect: { verify: verify("set search_path = ''") } }, +}); const clientHead = createClient(target, { - pgpOptions: { connect: { ssl: ca && { ca } } }, + pgpOptions: { + connect: { + ssl: ca && { ca }, + verify: verify("set role postgres; set search_path = ''"), + }, + }, }); const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? []; const excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? []; @@ -47,11 +76,6 @@ const extensionSchemas = [ ]; try { - // Step down from login role to postgres - await clientHead.query(sql`set role postgres`); - // Force schema qualified references for pg_get_expr - await clientHead.query(sql`set search_path = ''`); - await clientBase.query(sql`set search_path = ''`); const result: string[] = []; for (const schema of includedSchemas) { const m = await Migration.create(clientBase, clientHead, { diff --git a/apps/cli/src/commands/db/shared/migra.deno-templates.ts b/apps/cli/src/commands/db/shared/migra.deno-templates.ts index 786acfe77d..bb3a80c851 100644 --- a/apps/cli/src/commands/db/shared/migra.deno-templates.ts +++ b/apps/cli/src/commands/db/shared/migra.deno-templates.ts @@ -1,11 +1,11 @@ // Embedded templates for the migra rollback path (`db diff --use-migra`, -// `db pull --diff-engine migra`). `migra.deno-templates.unit.test.ts` checks -// that `migraDiffScript` contains its error sentinel; neither template's -// content is pinned by a test. +// `db pull --diff-engine migra`). `migra.deno-templates.unit.test.ts` runs +// `migraDiffScript` against in-memory pools to pin its per-connection session +// settings and error sentinel; `migraDiffShellScript` is not pinned by a test. /** `templates/migra.ts` — diffs SOURCE→TARGET via @pgkit/migra inside Edge Runtime. */ export const migraDiffScript = - 'import { createClient, sql } from "npm:@pgkit/client";\nimport { Migration } from "npm:@pgkit/migra";\n\n// Avoids error on self-signed certificate\nconst ca = Deno.env.get("SSL_CA");\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\nconst sslDebug = Deno.env.get("SUPABASE_SSL_DEBUG")?.toLowerCase() === "true";\n\nfunction redactPostgresUrl(raw: string | undefined): string {\n if (!raw) return "";\n try {\n const u = new URL(raw);\n if (u.password) u.password = "xxxxx";\n return u.toString();\n } catch {\n return "";\n }\n}\n\nif (sslDebug) {\n console.error(\n `[ssl-debug] migra.ts deno=${Deno.version.deno} v8=${Deno.version.v8} os=${Deno.build.os}`,\n );\n console.error(\n `[ssl-debug] migra.ts source=${redactPostgresUrl(source)} target=${redactPostgresUrl(target)}`,\n );\n console.error(\n `[ssl-debug] migra.ts ssl_ca_set=${ca != null} ssl_ca_len=${ca?.length ?? 0}`,\n );\n}\n\nconst clientBase = createClient(source);\nconst clientHead = createClient(target, {\n pgpOptions: { connect: { ssl: ca && { ca } } },\n});\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? [];\nconst excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? [];\n\nconst managedSchemas = ["auth", "realtime", "storage"];\nconst extensionSchemas = [\n "pg_catalog",\n "extensions",\n "pgmq",\n "tiger",\n "topology",\n];\n\ntry {\n // Step down from login role to postgres\n await clientHead.query(sql`set role postgres`);\n // Force schema qualified references for pg_get_expr\n await clientHead.query(sql`set search_path = \'\'`);\n await clientBase.query(sql`set search_path = \'\'`);\n const result: string[] = [];\n for (const schema of includedSchemas) {\n const m = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n if (managedSchemas.includes(schema)) {\n m.add(m.changes.triggers({ drops_only: true }));\n m.add(m.changes.rlspolicies({ drops_only: true }));\n m.add(m.changes.rlspolicies({ creations_only: true }));\n m.add(m.changes.triggers({ creations_only: true }));\n } else {\n m.add_all_changes(true);\n }\n result.push(m.sql);\n }\n if (includedSchemas.length === 0) {\n // Migra does not ignore custom types and triggers created by extensions, so we diff\n // them separately. This workaround only applies to a known list of managed schemas.\n for (const schema of extensionSchemas) {\n const e = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n e.set_safety(false);\n e.add(e.changes.schemas({ creations_only: true }));\n e.add_extension_changes();\n result.push(e.sql);\n }\n // Diff user defined entities in non-managed schemas, including extensions.\n const m = await Migration.create(clientBase, clientHead, {\n exclude_schema: [\n ...managedSchemas,\n ...extensionSchemas,\n ...excludedSchemas,\n ],\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n m.add_all_changes(true);\n result.push(m.sql);\n // For managed schemas, we want to include triggers and RLS policies only.\n for (const schema of managedSchemas) {\n const s = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n s.set_safety(false);\n s.add(s.changes.triggers({ drops_only: true }));\n s.add(s.changes.rlspolicies({ drops_only: true }));\n s.add(s.changes.rlspolicies({ creations_only: true }));\n s.add(s.changes.triggers({ creations_only: true }));\n result.push(s.sql);\n }\n }\n console.log(result.join(""));\n} catch (e) {\n if (sslDebug) {\n if (e instanceof Error) {\n console.error(\n `[ssl-debug] migra.ts error_name=${e.name} message=${e.message} stack=${e.stack ?? ""}`,\n );\n } else {\n console.error(`[ssl-debug] migra.ts error=${String(e)}`);\n }\n }\n console.error(e);\n console.error("PGDELTA_SCRIPT_ERROR");\n} finally {\n await Promise.all([clientHead.end(), clientBase.end()]);\n}\n'; + 'import { createClient } from "npm:@pgkit/client";\nimport { Migration } from "npm:@pgkit/migra";\n\n// Avoids error on self-signed certificate\nconst ca = Deno.env.get("SSL_CA");\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\nconst sslDebug = Deno.env.get("SUPABASE_SSL_DEBUG")?.toLowerCase() === "true";\n\nfunction redactPostgresUrl(raw: string | undefined): string {\n if (!raw) return "";\n try {\n const u = new URL(raw);\n if (u.password) u.password = "xxxxx";\n return u.toString();\n } catch {\n return "";\n }\n}\n\nif (sslDebug) {\n console.error(\n `[ssl-debug] migra.ts deno=${Deno.version.deno} v8=${Deno.version.v8} os=${Deno.build.os}`,\n );\n console.error(\n `[ssl-debug] migra.ts source=${redactPostgresUrl(source)} target=${redactPostgresUrl(target)}`,\n );\n console.error(\n `[ssl-debug] migra.ts ssl_ca_set=${ca != null} ssl_ca_len=${ca?.length ?? 0}`,\n );\n}\n\ntype PoolClient = {\n query: (stmt: string) => Promise;\n on: (event: "error", listener: () => void) => void;\n off: (event: "error", listener: () => void) => void;\n};\n// Step down from login role to postgres and force schema qualified references for\n// pg_get_expr on every pooled connection, including one reopened after an idle timeout\nconst verify = (stmt: string) => (client: PoolClient, done: (err?: Error) => void) => {\n // pg-pool drops its error listener during verify, so a socket error must reject, not throw\n const ignore = () => {};\n client.on("error", ignore);\n client\n .query(stmt)\n .finally(() => client.off("error", ignore))\n .then(\n () => done(),\n (err: Error) => {\n err.message = `${stmt}: ${err.message}`;\n done(err);\n },\n );\n};\nconst clientBase = createClient(source, {\n pgpOptions: { connect: { verify: verify("set search_path = \'\'") } },\n});\nconst clientHead = createClient(target, {\n pgpOptions: {\n connect: {\n ssl: ca && { ca },\n verify: verify("set role postgres; set search_path = \'\'"),\n },\n },\n});\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? [];\nconst excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? [];\n\nconst managedSchemas = ["auth", "realtime", "storage"];\nconst extensionSchemas = [\n "pg_catalog",\n "extensions",\n "pgmq",\n "tiger",\n "topology",\n];\n\ntry {\n const result: string[] = [];\n for (const schema of includedSchemas) {\n const m = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n if (managedSchemas.includes(schema)) {\n m.add(m.changes.triggers({ drops_only: true }));\n m.add(m.changes.rlspolicies({ drops_only: true }));\n m.add(m.changes.rlspolicies({ creations_only: true }));\n m.add(m.changes.triggers({ creations_only: true }));\n } else {\n m.add_all_changes(true);\n }\n result.push(m.sql);\n }\n if (includedSchemas.length === 0) {\n // Migra does not ignore custom types and triggers created by extensions, so we diff\n // them separately. This workaround only applies to a known list of managed schemas.\n for (const schema of extensionSchemas) {\n const e = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n e.set_safety(false);\n e.add(e.changes.schemas({ creations_only: true }));\n e.add_extension_changes();\n result.push(e.sql);\n }\n // Diff user defined entities in non-managed schemas, including extensions.\n const m = await Migration.create(clientBase, clientHead, {\n exclude_schema: [\n ...managedSchemas,\n ...extensionSchemas,\n ...excludedSchemas,\n ],\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n m.add_all_changes(true);\n result.push(m.sql);\n // For managed schemas, we want to include triggers and RLS policies only.\n for (const schema of managedSchemas) {\n const s = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n s.set_safety(false);\n s.add(s.changes.triggers({ drops_only: true }));\n s.add(s.changes.rlspolicies({ drops_only: true }));\n s.add(s.changes.rlspolicies({ creations_only: true }));\n s.add(s.changes.triggers({ creations_only: true }));\n result.push(s.sql);\n }\n }\n console.log(result.join(""));\n} catch (e) {\n if (sslDebug) {\n if (e instanceof Error) {\n console.error(\n `[ssl-debug] migra.ts error_name=${e.name} message=${e.message} stack=${e.stack ?? ""}`,\n );\n } else {\n console.error(`[ssl-debug] migra.ts error=${String(e)}`);\n }\n }\n console.error(e);\n console.error("PGDELTA_SCRIPT_ERROR");\n} finally {\n await Promise.all([clientHead.end(), clientBase.end()]);\n}\n'; /** `templates/migra.sh` — OOM bash fallback executed in the `supabase/migra` image. */ export const migraDiffShellScript = diff --git a/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts b/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts index 18fe42d6f2..16a1371a94 100644 --- a/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts +++ b/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts @@ -1,14 +1,240 @@ -import { describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; + +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect } from "effect"; import { dropObjectsSql } from "../../../command-internal/drop-objects.ts"; import { EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL } from "../../../command-internal/edge-runtime-script.service.ts"; import { migraDiffScript } from "./migra.deno-templates.ts"; import { listSchemasSql } from "./migra.ts"; +type Side = "source" | "target"; +type Verify = (connection: FakeConnection, done: (err?: Error) => void) => void; +type SetupFailure = "denyRole" | "dropConnection"; + +class SessionSetupError extends Data.TaggedError("SessionSetupError")<{ + readonly message: string; +}> {} + +class FakeConnection extends EventEmitter { + readonly session = { role: "cli_login_postgres", searchPath: '"$user", public' }; + readonly events: string[] = []; + readonly setupErrorListeners: number[] = []; + readonly dropErrorListeners: number[] = []; + + constructor( + readonly id: number, + private readonly failure: SetupFailure | undefined, + ) { + super(); + } + + query(stmt: string): Promise { + this.events.push("query"); + this.setupErrorListeners.push(this.listenerCount("error")); + if (this.failure === "dropConnection") { + return Promise.resolve().then(() => { + const err = new Error("Connection terminated unexpectedly"); + this.dropErrorListeners.push(this.listenerCount("error")); + this.emit("error", err); + throw err; + }); + } + return Promise.resolve().then(() => { + for (const part of stmt.split(";").map((s) => s.trim())) { + if (part === "set role postgres") { + if (this.failure === "denyRole") { + throw new Error('permission denied to set role "postgres"'); + } + this.session.role = "postgres"; + } else if (part === "set search_path = ''") { + this.session.searchPath = ""; + } + } + this.events.push("settled"); + }); + } +} + +// Runs the embedded script against in-memory pools whose new connections start with server +// defaults; `replaceConnections` hands out a fresh connection on every checkout. +const runMigraScript = (opts: { replaceConnections?: boolean; failure?: SetupFailure } = {}) => + Effect.suspend(() => { + const inspections: Array< + { side: Side; id: number; errorListeners: number } & FakeConnection["session"] + > = []; + const connections: FakeConnection[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const ended: Side[] = []; + const env: Record = { + SOURCE: "postgres://source", + TARGET: "postgres://target", + }; + + const createClient = ( + url: string, + options?: { pgpOptions?: { connect?: { verify?: Verify } } }, + ) => { + const side: Side = url === env.SOURCE ? "source" : "target"; + const verify = options?.pgpOptions?.connect?.verify; + let current: FakeConnection | undefined; + const openConnection = (): Effect.Effect => { + const fresh = new FakeConnection( + connections.length + 1, + side === "target" ? opts.failure : undefined, + ); + connections.push(fresh); + const setup = + verify === undefined + ? Effect.void + : Effect.callback((resume) => + verify(fresh, (err) => { + fresh.events.push(err === undefined ? "ready" : "failed"); + resume( + err === undefined + ? Effect.void + : Effect.fail(new SessionSetupError({ message: err.message })), + ); + }), + ); + return Effect.as(setup, fresh); + }; + const checkout: Effect.Effect = Effect.suspend(() => + current !== undefined && !opts.replaceConnections + ? Effect.succeed(current) + : Effect.map(openConnection(), (fresh) => (current = fresh)), + ); + return { + side, + checkout, + end: () => { + ended.push(side); + return Promise.resolve(); + }, + }; + }; + type Client = ReturnType; + + const noop = () => ""; + const migration = { + sql: "", + set_safety: noop, + add: noop, + add_all_changes: noop, + add_extension_changes: noop, + changes: { triggers: noop, rlspolicies: noop, schemas: noop }, + }; + const Migration = { + create: (base: Client, head: Client) => + Effect.runPromise( + Effect.gen(function* () { + for (const client of [base, head]) { + const connection = yield* client.checkout; + inspections.push({ + side: client.side, + id: connection.id, + errorListeners: connection.listenerCount("error"), + ...connection.session, + }); + } + return migration; + }), + ), + }; + const format = (args: unknown[]) => + args.map((arg) => (arg instanceof Error ? arg.message : String(arg))).join(" "); + + const module = new Bun.Transpiler({ loader: "ts" }).transformSync( + `export default async (createClient, Migration, Deno, console) => {\n${migraDiffScript.replaceAll(/^import .* from "npm:.*";$/gmu, "")}\n};`, + ); + return Effect.gen(function* () { + const script: { default: (...args: unknown[]) => Promise } = yield* Effect.promise( + () => import(`data:text/javascript;base64,${Buffer.from(module).toString("base64")}`), + ); + yield* Effect.promise(() => + script.default( + createClient, + Migration, + { env: { get: (key: string) => env[key] } }, + { + log: (...args: unknown[]) => stdout.push(format(args)), + error: (...args: unknown[]) => stderr.push(format(args)), + }, + ), + ); + return { inspections, connections, stdout, stderr, ended }; + }); + }); + describe("embedded migra templates", () => { it("emit the error sentinel from the diff script's failure path", () => { expect(migraDiffScript).toContain(EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL); }); + + it.effect( + "re-apply the session settings on every replacement connection (supabase/cli#6860)", + () => + Effect.gen(function* () { + const run = yield* runMigraScript({ replaceConnections: true }); + + expect(run.stderr).toEqual([]); + expect(run.stdout).toEqual([""]); + const sides: Side[] = ["source", "target"]; + for (const side of sides) { + expect( + run.inspections.filter((inspection) => inspection.side === side).length, + ).toBeGreaterThan(1); + } + for (const inspection of run.inspections) { + expect(inspection).toMatchObject({ + role: inspection.side === "target" ? "postgres" : "cli_login_postgres", + searchPath: "", + errorListeners: 0, + }); + } + for (const connection of run.connections) { + expect(connection.events).toEqual(["query", "settled", "ready"]); + expect(connection.setupErrorListeners).toEqual([1]); + } + }), + ); + + it.effect("report a failed session setup through the error sentinel", () => + Effect.gen(function* () { + const run = yield* runMigraScript({ failure: "denyRole" }); + + expect(run.stdout).toEqual([]); + expect(run.stderr).toEqual([ + `set role postgres; set search_path = '': permission denied to set role "postgres"`, + EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, + ]); + expect(run.ended.toSorted()).toEqual(["source", "target"]); + }), + ); + + it.effect("report a connection dropped during session setup through the error sentinel", () => + Effect.gen(function* () { + const run = yield* runMigraScript({ failure: "dropConnection" }); + + expect(run.stdout).toEqual([]); + expect(run.stderr).toEqual([ + "set role postgres; set search_path = '': Connection terminated unexpectedly", + EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, + ]); + expect(run.ended.toSorted()).toEqual(["source", "target"]); + const dropped = run.connections.filter( + (connection) => connection.dropErrorListeners.length > 0, + ); + expect(dropped).toHaveLength(1); + expect(dropped[0]).toMatchObject({ + events: ["query", "failed"], + setupErrorListeners: [1], + dropErrorListeners: [1], + }); + expect(dropped[0]?.listenerCount("error")).toBe(0); + }), + ); }); describe("embedded user-schema queries", () => { From 2a66b74151b9c40236ef51c819e4a60abee0aa1c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 29 Sep 2026 13:40:16 +0000 Subject: [PATCH 42/71] ci(repo): rebalance slow test jobs and raise integration workers (#6863) ## Problem A few jobs set the Test workflow's wall time: - Each runtime's whole-stack e2e file ran four independent scenarios one after another in a single job. - CLI e2e shards were split by hashed path, so one shard carried most of the long files. The replay-only `@supabase/cli-e2e` suite also ran after the Docker-backed suite in every shard. - Integration suites were capped at two workers. - Unit and integration jobs collected coverage that nothing consumed, and every job restored the Go caches. ## Change - The whole-stack e2e suites are split into `lifecycle` and `idle-parallel` files per runtime, each in its own job. The idle scenario uses a 5s idle timeout. - CLI e2e runs four shards balanced by measured file duration, plus `@supabase/cli-e2e` as its own job. The sequencer, the seeded `tests/e2e-timings.json` and the refresh script come from #6873. The timings file uses Bun's `--timings` format (`{ "version": 1, "files": { path: ms } }`), which Vitest is considering for its own duration-aware sharding. Each CLI shard uploads its Vitest results cache so the timings can be refreshed from CI. - Integration suites run with four workers. - Unit and integration jobs no longer collect coverage. - Jobs that never run Go skip the Go caches through a new `go-cache` setup input, which defaults to on. Only code quality and CLI e2e restore them. This covers #6872 and #6873. --------- Co-authored-by: Kanad Gupta --- .github/actions/setup/action.yml | 10 +- .github/workflows/test.yml | 44 ++++- apps/cli/scripts/refresh-e2e-timings.ts | 169 ++++++++++++++++++ .../scripts/refresh-e2e-timings.unit.test.ts | 103 +++++++++++ apps/cli/tests/e2e-sequencer.ts | 117 ++++++++++++ apps/cli/tests/e2e-sequencer.unit.test.ts | 161 +++++++++++++++++ apps/cli/tests/e2e-timings.json | 57 ++++++ apps/cli/vitest.config.ts | 6 +- packages/stack/README.md | 2 +- .../stack/src/whole-stack.docker.e2e.test.ts | 21 --- ...ole-stack.docker.idle-parallel.e2e.test.ts | 9 + .../whole-stack.docker.lifecycle.e2e.test.ts | 12 ++ .../stack/src/whole-stack.native.e2e.test.ts | 21 --- ...ole-stack.native.idle-parallel.e2e.test.ts | 9 + .../whole-stack.native.lifecycle.e2e.test.ts | 12 ++ packages/stack/tests/whole-stack/fixture.ts | 5 +- packages/stack/tests/whole-stack/idle.ts | 5 +- packages/stack/vitest.config.ts | 2 +- 18 files changed, 708 insertions(+), 57 deletions(-) create mode 100644 apps/cli/scripts/refresh-e2e-timings.ts create mode 100644 apps/cli/scripts/refresh-e2e-timings.unit.test.ts create mode 100644 apps/cli/tests/e2e-sequencer.ts create mode 100644 apps/cli/tests/e2e-sequencer.unit.test.ts create mode 100644 apps/cli/tests/e2e-timings.json delete mode 100644 packages/stack/src/whole-stack.docker.e2e.test.ts create mode 100644 packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts create mode 100644 packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts delete mode 100644 packages/stack/src/whole-stack.native.e2e.test.ts create mode 100644 packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts create mode 100644 packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 0f61b31ba0..a05bc49616 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -14,6 +14,12 @@ inputs: fails with a path validation error. required: false default: "true" + go-cache: + description: >- + Whether to restore the Go module and build caches. Disable this in jobs + that never run Go. + required: false + default: "true" runs: using: "composite" @@ -70,7 +76,7 @@ runs: key: pnpm-verify-${{ runner.os }}-${{ inputs.dependency-firewall-token != '' && 'firewall' || 'public' }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} - name: Resolve Go cache paths - if: inputs.dependency-cache == 'true' + if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' id: go-cache shell: bash run: | @@ -78,7 +84,7 @@ runs: echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" - name: Configure Go dependency cache - if: inputs.dependency-cache == 'true' + if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bac40c3a32..391542b18e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,9 +95,10 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run unit tests - run: pnpm run test:unit --coverage.enabled + run: pnpm run test:unit test-integration: if: | @@ -117,9 +118,10 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run integration tests - run: pnpm run test:integration --coverage.enabled + run: pnpm run test:integration test-stack-ports: if: | @@ -145,6 +147,7 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run stack integration tests working-directory: packages/stack @@ -191,12 +194,17 @@ jobs: (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run CLI end-to-end tests (shard ${{ matrix.shard }}/3) + name: Run CLI end-to-end tests (${{ matrix.name }}) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + include: + - { suite: cli, shard: 1, shards: 4, name: cli 1/4 } + - { suite: cli, shard: 2, shards: 4, name: cli 2/4 } + - { suite: cli, shard: 3, shards: 4, name: cli 3/4 } + - { suite: cli, shard: 4, shards: 4, name: cli 4/4 } + - { suite: cli-e2e, name: cli-e2e } steps: - name: Checkout uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta @@ -229,22 +237,41 @@ jobs: run: pnpm exec turbo run supabase#build - name: Run end-to-end tests - run: pnpm exec turbo run test:e2e:run --only --concurrency=1 --filter=supabase --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/3 + if: matrix.suite == 'cli' + run: pnpm exec turbo run test:e2e:run --only --filter=supabase -- --shard=${{ matrix.shard }}/${{ matrix.shards }} env: SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go + - name: Run cli-e2e end-to-end tests + if: matrix.suite == 'cli-e2e' + run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e + env: + SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go + + # Vitest writes its results cache under a sha1-named directory. The refresh script + # (apps/cli/scripts/refresh-e2e-timings.ts) merges these into tests/e2e-timings.json. + - name: Upload e2e timings + if: always() && matrix.suite == 'cli' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-timings-${{ matrix.shard }} + path: apps/cli/node_modules/.vite/vitest/**/results.json + retention-days: 7 + if-no-files-found: warn + test-stack-e2e: if: | !startsWith(github.head_ref, 'release-notes/') && (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run stack end-to-end tests (${{ matrix.runtime }}) + name: Run stack end-to-end tests (${{ matrix.runtime }}, ${{ matrix.suite }}) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false matrix: runtime: [native, docker] + suite: [lifecycle, idle-parallel] steps: - name: Checkout uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta @@ -253,15 +280,16 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run stack end-to-end tests run: >- pnpm --filter @supabase/stack test:e2e:run - src/whole-stack.${{ matrix.runtime }}.e2e.test.ts + src/whole-stack.${{ matrix.runtime }}.${{ matrix.suite }}.e2e.test.ts --passWithNoTests=false - name: Run public stack end-to-end tests - if: matrix.runtime == 'native' + if: matrix.runtime == 'native' && matrix.suite == 'lifecycle' run: >- pnpm --filter @supabase/stack test:e2e:run src/public.e2e.test.ts diff --git a/apps/cli/scripts/refresh-e2e-timings.ts b/apps/cli/scripts/refresh-e2e-timings.ts new file mode 100644 index 0000000000..dd5396410b --- /dev/null +++ b/apps/cli/scripts/refresh-e2e-timings.ts @@ -0,0 +1,169 @@ +// Rewrites apps/cli/tests/e2e-timings.json from the Vitest results caches that the test-e2e +// workflow job uploads as `e2e-timings-` artifacts. +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { version as installedVitestVersion } from "vitest/node"; + +export interface VitestFileResult { + readonly duration: number; + readonly failed: boolean; +} + +/** Shape of Vitest's `results.json` cache: `[:, result]` entries. */ +export interface VitestResultsCache { + readonly version: string; + readonly results: ReadonlyArray; +} + +export interface MergeOptions { + /** Version of the Vitest that will consume the timings; caches from another major are rejected. */ + readonly vitestVersion: string; + readonly warn?: (message: string) => void; +} + +const PROJECT_PREFIX = "e2e:"; + +function major(version: string): string { + return version.split(".")[0] ?? version; +} + +/** + * Merges e2e project entries from several results caches into `{ path: ms }`, sorted by path. + * Failed entries are skipped since their duration reflects the failure, not the file. + */ +export function mergeTimings( + caches: ReadonlyArray, + { vitestVersion, warn = () => {} }: MergeOptions, +): Record { + const merged = new Map(); + for (const cache of caches) { + if (major(cache.version) !== major(vitestVersion)) { + throw new Error( + `results cache was written by Vitest ${cache.version} but Vitest ${vitestVersion} is installed`, + ); + } + for (const [key, result] of cache.results) { + if (!key.startsWith(PROJECT_PREFIX)) { + continue; + } + const path = key.slice(PROJECT_PREFIX.length); + if (result.failed) { + warn(`skipping failed ${path}`); + continue; + } + const duration = Math.round(result.duration); + merged.set(path, Math.max(merged.get(path) ?? 0, duration)); + } + } + return Object.fromEntries([...merged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} + +function isFileResult(value: unknown): value is VitestFileResult { + return ( + typeof value === "object" && + value !== null && + "duration" in value && + typeof value.duration === "number" && + "failed" in value && + typeof value.failed === "boolean" + ); +} + +function isResultsEntry(value: unknown): value is readonly [string, VitestFileResult] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "string" && + isFileResult(value[1]) + ); +} + +function isResultsCache(value: unknown): value is VitestResultsCache { + return ( + typeof value === "object" && + value !== null && + "version" in value && + typeof value.version === "string" && + "results" in value && + Array.isArray(value.results) && + value.results.every(isResultsEntry) + ); +} + +function readResultsCaches(dir: string): VitestResultsCache[] { + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name === "results.json") + .map((entry) => { + const file = join(entry.parentPath, entry.name); + const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); + if (!isResultsCache(parsed)) { + throw new Error(`${file} is not a Vitest results cache`); + } + return parsed; + }); +} + +const usage = `Usage: pnpm exec bun apps/cli/scripts/refresh-e2e-timings.ts --run [--repo supabase/cli] + + Downloads the e2e-timings-* artifacts of a test workflow run and rewrites + apps/cli/tests/e2e-timings.json from them.`; + +if (import.meta.main) { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { run: { type: "string" }, repo: { type: "string", default: "supabase/cli" } }, + }); + if (!values.run) { + console.error(usage); + process.exit(2); + } + + const downloadDir = mkdtempSync(join(tmpdir(), "e2e-timings-")); + try { + const download = spawnSync( + "gh", + [ + "run", + "download", + values.run, + "--repo", + values.repo, + "--pattern", + "e2e-timings-*", + "--dir", + downloadDir, + ], + { stdio: "inherit" }, + ); + if (download.error !== undefined) { + throw new Error(`could not run gh: ${download.error.message}`); + } + if (download.status !== 0) { + throw new Error( + `gh run download exited with ${download.status ?? `signal ${download.signal}`}`, + ); + } + + const caches = readResultsCaches(downloadDir); + if (caches.length === 0) { + throw new Error(`no results.json found in the e2e-timings-* artifacts of run ${values.run}`); + } + const timings = mergeTimings(caches, { + vitestVersion: installedVitestVersion, + warn: (message) => console.error(message), + }); + const target = fileURLToPath(new URL("../tests/e2e-timings.json", import.meta.url)); + writeFileSync(target, `${JSON.stringify({ version: 1, files: timings }, null, 2)}\n`); + console.error(`wrote ${Object.keys(timings).length} timings to ${target}`); + } catch (cause) { + console.error(`error: ${cause instanceof Error ? cause.message : String(cause)}`); + process.exitCode = 1; + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +} diff --git a/apps/cli/scripts/refresh-e2e-timings.unit.test.ts b/apps/cli/scripts/refresh-e2e-timings.unit.test.ts new file mode 100644 index 0000000000..8571e883f7 --- /dev/null +++ b/apps/cli/scripts/refresh-e2e-timings.unit.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; +import { mergeTimings, type VitestResultsCache } from "./refresh-e2e-timings.ts"; + +const vitestVersion = "5.0.0"; + +function cache(...results: VitestResultsCache["results"]): VitestResultsCache { + return { version: vitestVersion, results }; +} + +describe("mergeTimings", () => { + test("keeps only e2e project entries and strips the project prefix", () => { + const timings = mergeTimings( + [ + cache( + ["e2e:src/start.e2e.test.ts", { duration: 1200, failed: false }], + ["unit:src/start.unit.test.ts", { duration: 5, failed: false }], + [":src/start.integration.test.ts", { duration: 40, failed: false }], + ), + ], + { vitestVersion }, + ); + + expect(timings).toEqual({ "src/start.e2e.test.ts": 1200 }); + }); + + test("skips failed entries and reports each one", () => { + const warnings: string[] = []; + + const timings = mergeTimings( + [ + cache( + ["e2e:src/ok.e2e.test.ts", { duration: 100, failed: false }], + ["e2e:src/broken.e2e.test.ts", { duration: 5, failed: true }], + ), + ], + { vitestVersion, warn: (message) => warnings.push(message) }, + ); + + expect(timings).toEqual({ "src/ok.e2e.test.ts": 100 }); + expect(warnings).toEqual(["skipping failed src/broken.e2e.test.ts"]); + }); + + test("takes the longest duration when a file appears in several caches", () => { + const timings = mergeTimings( + [ + cache(["e2e:src/shared.e2e.test.ts", { duration: 300, failed: false }]), + cache(["e2e:src/shared.e2e.test.ts", { duration: 450, failed: false }]), + cache(["e2e:src/shared.e2e.test.ts", { duration: 120, failed: false }]), + ], + { vitestVersion }, + ); + + expect(timings).toEqual({ "src/shared.e2e.test.ts": 450 }); + }); + + test("rounds durations to whole milliseconds", () => { + const timings = mergeTimings( + [cache(["e2e:src/fast.e2e.test.ts", { duration: 99.6, failed: false }])], + { vitestVersion }, + ); + + expect(timings).toEqual({ "src/fast.e2e.test.ts": 100 }); + }); + + test("orders the output by path so the committed file diffs cleanly", () => { + const timings = mergeTimings( + [ + cache( + ["e2e:src/zeta.e2e.test.ts", { duration: 1, failed: false }], + ["e2e:scripts/alpha.e2e.test.ts", { duration: 2, failed: false }], + ["e2e:src/beta.e2e.test.ts", { duration: 3, failed: false }], + ), + ], + { vitestVersion }, + ); + + expect(Object.keys(timings)).toEqual([ + "scripts/alpha.e2e.test.ts", + "src/beta.e2e.test.ts", + "src/zeta.e2e.test.ts", + ]); + }); + + test("rejects a cache written by a different Vitest major", () => { + const stale: VitestResultsCache = { + version: "4.2.1", + results: [["e2e:src/a.e2e.test.ts", { duration: 1, failed: false }]], + }; + + expect(() => mergeTimings([stale], { vitestVersion })).toThrow( + "results cache was written by Vitest 4.2.1 but Vitest 5.0.0 is installed", + ); + }); + + test("accepts a cache from a different minor of the same major", () => { + const newer: VitestResultsCache = { + version: "5.3.0", + results: [["e2e:src/a.e2e.test.ts", { duration: 1, failed: false }]], + }; + + expect(mergeTimings([newer], { vitestVersion })).toEqual({ "src/a.e2e.test.ts": 1 }); + }); +}); diff --git a/apps/cli/tests/e2e-sequencer.ts b/apps/cli/tests/e2e-sequencer.ts new file mode 100644 index 0000000000..576b9e16bd --- /dev/null +++ b/apps/cli/tests/e2e-sequencer.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { BaseSequencer, type TestSpecification } from "vitest/node"; + +/** Bun `--timings` file of measured e2e file durations, refreshed from CI artifacts. */ +const TIMINGS_FILE = "tests/e2e-timings.json"; + +/** Duration assumed for every file while no timing has been measured yet. */ +export const DEFAULT_DURATION_MS = 30_000; + +/** The median of the measured durations, or the default when nothing has been measured. */ +export function fallbackDuration(timings: Readonly>): number { + const measured = Object.values(timings).sort((a, b) => a - b); + if (measured.length === 0) { + return DEFAULT_DURATION_MS; + } + const middle = Math.floor(measured.length / 2); + const upper = measured[middle] ?? DEFAULT_DURATION_MS; + if (measured.length % 2 === 1) { + return upper; + } + const lower = measured[middle - 1] ?? upper; + return (lower + upper) / 2; +} + +function compareKeys(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Splits keys into `count` shards by measured duration, longest first into the emptiest shard. + * The result depends only on the set of keys, so every shard process derives the same partition. + */ +export function packShards( + keys: readonly string[], + timings: Readonly>, + count: number, +): string[][] { + const fallback = fallbackDuration(timings); + const durations = keys + .map((key) => ({ key, duration: timings[key] ?? fallback })) + .sort((a, b) => b.duration - a.duration || compareKeys(a.key, b.key)); + + const shards = Array.from({ length: count }, () => ({ load: 0, keys: new Array() })); + for (const { key, duration } of durations) { + let target = shards[0]; + for (const shard of shards) { + if (target === undefined || shard.load < target.load) { + target = shard; + } + } + if (target !== undefined) { + target.load += duration; + target.keys.push(key); + } + } + return shards.map((shard) => shard.keys); +} + +function isFilesMap(value: unknown): value is Readonly> { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every( + (duration) => typeof duration === "number" && Number.isFinite(duration), + ) + ); +} + +function isTimingsEnvelope( + value: unknown, +): value is { version: 1; files: Readonly> } { + return ( + typeof value === "object" && + value !== null && + "version" in value && + value.version === 1 && + "files" in value && + isFilesMap(value.files) + ); +} + +/** Reads the committed Bun `--timings` file under `root`; anything but that envelope reads as absent. */ +export function readTimings(root: string): Readonly> | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(resolve(root, TIMINGS_FILE), "utf8")); + return isTimingsEnvelope(parsed) ? parsed.files : undefined; + } catch { + return undefined; + } +} + +/** + * Shards the e2e project by measured duration. Other projects, and the e2e project while + * the timings file is absent, keep Vitest's hash split. + */ +export class E2eSequencer extends BaseSequencer { + override async shard(files: TestSpecification[]): Promise { + const { root, shard } = this.ctx.config; + const timings = files.every((spec) => spec.project.name === "e2e") + ? readTimings(root) + : undefined; + if (shard === undefined || timings === undefined) { + return super.shard(files); + } + + const byKey = new Map( + files.map((spec) => [relative(root, spec.moduleId).replaceAll("\\", "/"), spec]), + ); + const assigned = packShards([...byKey.keys()], timings, shard.count)[shard.index - 1] ?? []; + return assigned.flatMap((key) => { + const spec = byKey.get(key); + return spec === undefined ? [] : [spec]; + }); + } +} diff --git a/apps/cli/tests/e2e-sequencer.unit.test.ts b/apps/cli/tests/e2e-sequencer.unit.test.ts new file mode 100644 index 0000000000..1826cab568 --- /dev/null +++ b/apps/cli/tests/e2e-sequencer.unit.test.ts @@ -0,0 +1,161 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { DEFAULT_DURATION_MS, fallbackDuration, packShards, readTimings } from "./e2e-sequencer.ts"; + +const files = ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "src/d.e2e.test.ts"]; + +const sortedShards = (shards: readonly (readonly string[])[]) => + shards.map((shard) => [...shard].sort()); + +describe("packShards", () => { + test.each([1, files.length, files.length + 3])( + "partitions every file into exactly one of %i shards", + (count) => { + const shards = packShards(files, { "src/a.e2e.test.ts": 100 }, count); + + expect(shards).toHaveLength(count); + expect(shards.flat().sort()).toEqual([...files].sort()); + }, + ); + + test("leaves trailing shards empty when there are more shards than files", () => { + const shards = packShards(["src/a.e2e.test.ts", "src/b.e2e.test.ts"], {}, 4); + + expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], [], []]); + }); + + test("produces the same partition regardless of input order", () => { + const timings = { + "src/a.e2e.test.ts": 500, + "src/b.e2e.test.ts": 200, + "src/c.e2e.test.ts": 900, + }; + + const forward = packShards(files, timings, 2); + const reversed = packShards([...files].reverse(), timings, 2); + + expect(reversed).toEqual(forward); + }); + + test("isolates a file that outweighs all the others combined", () => { + const timings = { + "src/a.e2e.test.ts": 10, + "src/b.e2e.test.ts": 1_000, + "src/c.e2e.test.ts": 10, + "src/d.e2e.test.ts": 10, + }; + + const shards = packShards(files, timings, 2); + + expect(sortedShards(shards)).toEqual([ + ["src/b.e2e.test.ts"], + ["src/a.e2e.test.ts", "src/c.e2e.test.ts", "src/d.e2e.test.ts"], + ]); + }); + + test("weights an unmeasured file at the median of the measured ones", () => { + const timings = { + "src/a.e2e.test.ts": 100, + "src/b.e2e.test.ts": 300, + "src/c.e2e.test.ts": 500, + }; + + // d is unmeasured; at the median (300) it ties with b and follows it into the second shard. + const shards = packShards(files, timings, 2); + + expect(sortedShards(shards)).toEqual([ + ["src/a.e2e.test.ts", "src/c.e2e.test.ts"], + ["src/b.e2e.test.ts", "src/d.e2e.test.ts"], + ]); + }); + + test("weights every file at the default duration when nothing has been measured", () => { + const allDefault = Object.fromEntries(files.map((file) => [file, DEFAULT_DURATION_MS])); + + expect(packShards(files, {}, 3)).toEqual(packShards(files, allDefault, 3)); + }); + + test("breaks duration ties by key so equal files spread deterministically", () => { + const timings = { "src/c.e2e.test.ts": 50, "src/a.e2e.test.ts": 50, "src/b.e2e.test.ts": 50 }; + + const shards = packShards(Object.keys(timings), timings, 3); + + expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], ["src/c.e2e.test.ts"]]); + }); +}); + +describe("fallbackDuration", () => { + test("is the default duration when nothing has been measured", () => { + expect(fallbackDuration({})).toBe(DEFAULT_DURATION_MS); + }); + + test("is the middle value for an odd number of measurements", () => { + expect(fallbackDuration({ a: 900, b: 100, c: 300 })).toBe(300); + }); + + test("is the mean of the two middle values for an even number of measurements", () => { + expect(fallbackDuration({ a: 100, b: 200, c: 400, d: 900 })).toBe(300); + }); +}); + +describe("readTimings", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "e2e-timings-")); + mkdirSync(join(root, "tests")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const writeTimings = (content: string) => + writeFileSync(join(root, "tests", "e2e-timings.json"), content); + + test("reads the files map from a version 1 envelope", () => { + writeTimings( + JSON.stringify({ + version: 1, + files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }, + }), + ); + + expect(readTimings(root)).toEqual({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }); + }); + + test("reads as absent when the file is missing", () => { + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent rather than half-applying when any duration is not a number", () => { + writeTimings( + JSON.stringify({ + version: 1, + files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": "300" }, + }), + ); + + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent when the file is not JSON", () => { + writeTimings("{ not json"); + + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent for a flat map with no envelope", () => { + writeTimings(JSON.stringify({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 })); + + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent for an unknown envelope version", () => { + writeTimings(JSON.stringify({ version: 2, files: { "src/a.e2e.test.ts": 1200 } })); + + expect(readTimings(root)).toBeUndefined(); + }); +}); diff --git a/apps/cli/tests/e2e-timings.json b/apps/cli/tests/e2e-timings.json new file mode 100644 index 0000000000..161bf498ed --- /dev/null +++ b/apps/cli/tests/e2e-timings.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "files": { + "scripts/publish-docs-spec.e2e.test.ts": 196, + "src/cli/agent-output.e2e.test.ts": 1179, + "src/cli/complete.e2e.test.ts": 1390, + "src/cli/main.e2e.test.ts": 295, + "src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts": 64985, + "src/commands/completion/completion.e2e.test.ts": 485, + "src/commands/config/diff/diff.e2e.test.ts": 293, + "src/commands/config/pull/pull.e2e.test.ts": 562, + "src/commands/config/push/push.e2e.test.ts": 1016, + "src/commands/db/diff/diff.declarative.e2e.test.ts": 64523, + "src/commands/db/diff/diff.e2e.test.ts": 276, + "src/commands/db/diff/diff.stack-cache.e2e.test.ts": 17572, + "src/commands/db/pull/pull.e2e.test.ts": 235, + "src/commands/db/push/push.e2e.test.ts": 680, + "src/commands/db/reset/reset.e2e.test.ts": 232, + "src/commands/db/reset/reset.stack.e2e.test.ts": 11954, + "src/commands/db/schema/declarative/sync/sync.e2e.test.ts": 86790, + "src/commands/db/start/start.e2e.test.ts": 37852, + "src/commands/db/start/start.roles.e2e.test.ts": 14108, + "src/commands/encryption/encryption.e2e.test.ts": 406, + "src/commands/experimental/stack/start/start.e2e.test.ts": 10026, + "src/commands/feedback/add/add.e2e.test.ts": 176, + "src/commands/functions/deploy/deploy.e2e.test.ts": 1196, + "src/commands/functions/download/download.e2e.test.ts": 846, + "src/commands/functions/serve/serve.stack.e2e.test.ts": 21998, + "src/commands/gen/signing-key/signing-key.e2e.test.ts": 401, + "src/commands/gen/types/types.e2e.test.ts": 3983, + "src/commands/inspect/db/inspect-db.e2e.test.ts": 391, + "src/commands/inspect/report/report.e2e.test.ts": 197, + "src/commands/link/link.e2e.test.ts": 263, + "src/commands/login/login.e2e.test.ts": 437, + "src/commands/logout/logout.e2e.test.ts": 722, + "src/commands/migration/fetch/fetch.e2e.test.ts": 223, + "src/commands/migration/new/new.e2e.test.ts": 189, + "src/commands/migration/squash/squash.e2e.test.ts": 417, + "src/commands/pull/pull.e2e.test.ts": 396, + "src/commands/seed/buckets/buckets.e2e.test.ts": 771, + "src/commands/snippets/snippets.e2e.test.ts": 297, + "src/commands/sso/sso.e2e.test.ts": 1554, + "src/commands/start/start.e2e.test.ts": 199, + "src/commands/start/start.lifecycle.e2e.test.ts": 152494, + "src/commands/start/start.slim-images.e2e.test.ts": 69840, + "src/commands/status/status.e2e.test.ts": 27905, + "src/commands/stop/stop.e2e.test.ts": 59483, + "src/commands/storage/storage.e2e.test.ts": 1072, + "src/commands/test/new/new.e2e.test.ts": 326, + "src/commands/unlink/unlink.e2e.test.ts": 587, + "src/commands/whoami/whoami.e2e.test.ts": 214, + "src/shared/cli/run.e2e.test.ts": 2252, + "src/shared/functions/serve-main-offline.e2e.test.ts": 8693, + "src/shared/telemetry/failure-metadata.e2e.test.ts": 550, + "src/shared/telemetry/posthog-client.e2e.test.ts": 2277 + } +} diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 72c8653981..8c65253e02 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { defaultClientConditions, defaultServerConditions } from "vite"; import { defineConfig } from "vitest/config"; +import { E2eSequencer } from "./tests/e2e-sequencer.ts"; function dockerfileTextPlugin() { return { @@ -28,6 +29,9 @@ export default defineConfig({ plugins: [dockerfileTextPlugin()], test: { passWithNoTests: true, + // Vitest reads `sequence.sequencer` from the root config only; the sequencer + // itself applies duration-aware sharding to the e2e project alone. + sequence: { sequencer: E2eSequencer }, coverage: { enabled: false, provider: "v8", @@ -62,7 +66,7 @@ export default defineConfig({ hookTimeout: 120_000, include: ["**/*.integration.test.ts"], // Integration workers start real service processes and containers. - maxWorkers: 2, + maxWorkers: 4, sequence: { groupOrder: 1 }, }, }, diff --git a/packages/stack/README.md b/packages/stack/README.md index f756b8a015..5363725a53 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -120,7 +120,7 @@ Inputs that the composition binds or the owner fills from the stack credentials, To recompose a stopped stack, pass the IDs to keep in `reuseIds`. `composition.plan(services)` compares requested creations with every saved instance of the same kinds without contacting the owner or changing state. Each entry reports its instance `id`, `service`, whether it is a composition `member`, and a `change`: `unchanged`, `changed` with the differing config `paths`, or `incompatible` with the `paths` of an endpoint, artifact version, or PostgreSQL major-version change that the saved instance cannot adopt. Inputs that the composition or the stack credentials supply are ignored, an automatic API port is compared as the shared fixed port the composition would assign, and a PostgreSQL major alias matches its pinned version. Recomposing with `reuseIds` replaces a `changed` instance's configuration while keeping its identity, data, and ports. -The whole-stack E2E suite covers the default lazy lifecycle and reopen, all-eager startup, idle and wake, and parallel stack isolation for native and Docker runtimes. Run one runtime with `pnpm --filter @supabase/stack test:e2e:run src/whole-stack.native.e2e.test.ts` or the corresponding Docker test file. +The whole-stack E2E suite covers the default lazy lifecycle and reopen, all-eager startup, idle and wake, and parallel stack isolation for native and Docker runtimes, split into a `lifecycle` and an `idle-parallel` file per runtime. Run one runtime with a filter such as `pnpm --filter @supabase/stack test:e2e:run src/whole-stack.native` (matches both native files) or target one file, for example `src/whole-stack.docker.idle-parallel.e2e.test.ts`. For multiple instances or custom dependencies, configure members and bindings explicitly instead: diff --git a/packages/stack/src/whole-stack.docker.e2e.test.ts b/packages/stack/src/whole-stack.docker.e2e.test.ts deleted file mode 100644 index 2cbaf21608..0000000000 --- a/packages/stack/src/whole-stack.docker.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { it } from "@effect/vitest"; -import { Effect } from "effect"; -import { allEager, defaultLifecycle, parallel } from "../tests/whole-stack/scenarios.ts"; -import { idleWake } from "../tests/whole-stack/idle.ts"; -import { servicesLayer } from "../tests/whole-stack/fixture.ts"; - -const run = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(servicesLayer)); - -it.live( - "Docker: default database eager lifecycle and reopen", - () => run(defaultLifecycle("docker")), - { - timeout: 15 * 60_000, - }, -); -it.live("Docker: all services eager", () => run(allEager("docker")), { timeout: 15 * 60_000 }); -it.live("Docker: idle and wake", () => run(idleWake("docker")), { timeout: 10 * 60_000 }); -it.live("Docker: parallel stack isolation", () => run(parallel("docker")), { - timeout: 30 * 60_000, -}); diff --git a/packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts b/packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts new file mode 100644 index 0000000000..a18f68ff23 --- /dev/null +++ b/packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts @@ -0,0 +1,9 @@ +import { it } from "@effect/vitest"; +import { parallel } from "../tests/whole-stack/scenarios.ts"; +import { idleWake } from "../tests/whole-stack/idle.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live("Docker: idle and wake", () => run(idleWake("docker")), { timeout: 10 * 60_000 }); +it.live("Docker: parallel stack isolation", () => run(parallel("docker")), { + timeout: 30 * 60_000, +}); diff --git a/packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts b/packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts new file mode 100644 index 0000000000..53aa5d5dd0 --- /dev/null +++ b/packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts @@ -0,0 +1,12 @@ +import { it } from "@effect/vitest"; +import { allEager, defaultLifecycle } from "../tests/whole-stack/scenarios.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live( + "Docker: default database eager lifecycle and reopen", + () => run(defaultLifecycle("docker")), + { + timeout: 15 * 60_000, + }, +); +it.live("Docker: all services eager", () => run(allEager("docker")), { timeout: 15 * 60_000 }); diff --git a/packages/stack/src/whole-stack.native.e2e.test.ts b/packages/stack/src/whole-stack.native.e2e.test.ts deleted file mode 100644 index 70ca2a6835..0000000000 --- a/packages/stack/src/whole-stack.native.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { it } from "@effect/vitest"; -import { Effect } from "effect"; -import { allEager, defaultLifecycle, parallel } from "../tests/whole-stack/scenarios.ts"; -import { idleWake } from "../tests/whole-stack/idle.ts"; -import { servicesLayer } from "../tests/whole-stack/fixture.ts"; - -const run = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(servicesLayer)); - -it.live( - "native: default database eager lifecycle and reopen", - () => run(defaultLifecycle("native")), - { - timeout: 15 * 60_000, - }, -); -it.live("native: all services eager", () => run(allEager("native")), { timeout: 15 * 60_000 }); -it.live("native: idle and wake", () => run(idleWake("native")), { timeout: 10 * 60_000 }); -it.live("native: parallel stack isolation", () => run(parallel("native")), { - timeout: 30 * 60_000, -}); diff --git a/packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts b/packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts new file mode 100644 index 0000000000..16fdcce8be --- /dev/null +++ b/packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts @@ -0,0 +1,9 @@ +import { it } from "@effect/vitest"; +import { parallel } from "../tests/whole-stack/scenarios.ts"; +import { idleWake } from "../tests/whole-stack/idle.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live("native: idle and wake", () => run(idleWake("native")), { timeout: 10 * 60_000 }); +it.live("native: parallel stack isolation", () => run(parallel("native")), { + timeout: 30 * 60_000, +}); diff --git a/packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts b/packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts new file mode 100644 index 0000000000..d78a02f169 --- /dev/null +++ b/packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts @@ -0,0 +1,12 @@ +import { it } from "@effect/vitest"; +import { allEager, defaultLifecycle } from "../tests/whole-stack/scenarios.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live( + "native: default database eager lifecycle and reopen", + () => run(defaultLifecycle("native")), + { + timeout: 15 * 60_000, + }, +); +it.live("native: all services eager", () => run(allEager("native")), { timeout: 15 * 60_000 }); diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index f4b4785f12..6c3675f049 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -361,4 +361,7 @@ export const waitForLifecycle = Effect.fn("WholeStack.waitForLifecycle")( ), ); -export const servicesLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +const servicesLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + +export const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(servicesLayer)); diff --git a/packages/stack/tests/whole-stack/idle.ts b/packages/stack/tests/whole-stack/idle.ts index b781328220..67e3f46289 100644 --- a/packages/stack/tests/whole-stack/idle.ts +++ b/packages/stack/tests/whole-stack/idle.ts @@ -19,6 +19,9 @@ class IdleProbeError extends Schema.TaggedError()("IdleProbeErro cause: Schema.optionalKey(Schema.Unknown), }) {} +// At least 5s, so a service can't idle out between back-to-back requests on a slow runner. +const idleMillis = 5_000; + const signServiceToken = Effect.fn("WholeStack.signServiceToken")((secret: string) => Effect.tryPromise({ try: () => @@ -48,7 +51,7 @@ export const idleWake = (runtime: Runtime) => member.id === database.id ? { id: member.id, activation: "eager" as const } : timedServices.has(member.id) - ? { id: member.id, activation: "lazy" as const, idleMillis: 15_000 } + ? { id: member.id, activation: "lazy" as const, idleMillis } : { id: member.id, activation: "lazy" as const }, ), }); diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 638d2ef600..88e8c7d186 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ hookTimeout: 120_000, testTimeout: 30_000, // Integration workers start real service processes and containers. - maxWorkers: 2, + maxWorkers: 4, sequence: { groupOrder: 1 }, }, }, From 288edc26628844b6a09c378a126a200bcd2adeae Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 08:12:19 +0000 Subject: [PATCH 43/71] fix(cli): show stack connection details on start and status (CLI-2546, CLI-2531, CLI-2530) (#6894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** With the experimental stack backend, `supabase start` now prints every URL a developer needs (API, REST, Functions, Studio, MCP, Mailpit, database) plus the local keys, and `supabase status` gets a readable grouped layout instead of a debug dump. Log lines emitted while the start spinner is running no longer land on the spinner's row. Ports are assigned per project, so the MCP URL is now reported by `start`, `status`, and `status --env`. ### Before ```mermaid flowchart LR A["supabase start"] --> B["Spinner row"] C["Seeding / WARN lines"] -->|written on same row| B B --> D["Stack is ready."] D --> E["No URLs or keys"] F["supabase status"] --> G["Flat dump with IDs"] ``` ### After ```mermaid flowchart LR A["supabase start"] --> B["Spinner row"] C["Seeding / warning lines"] -->|pause, print, resume| B B --> D["Stack is ready."] D --> E["URLs incl. MCP, keys,
services, runtime"] F["supabase status"] --> G["Header, grouped tables,
services, drift line"] ``` ### Terminal captures Recorded with `SUPABASE_EXPERIMENTAL_STACK=1` on Docker against the base commit and this branch. **`supabase start` (before):** log lines land on the spinner row, and nothing follows `Stack is ready.` ![supabase start before](https://gist.githubusercontent.com/avallete/995aaaa5d98c57278d7ff3cc9c8d26f0/raw/before-start.gif) **`supabase start` (after):** ![supabase start after](https://gist.githubusercontent.com/avallete/995aaaa5d98c57278d7ff3cc9c8d26f0/raw/after-start.gif) **`supabase status` (before):** ![supabase status before](https://gist.githubusercontent.com/avallete/995aaaa5d98c57278d7ff3cc9c8d26f0/raw/before-status.png) **`supabase status` (after):** ![supabase status after](https://gist.githubusercontent.com/avallete/995aaaa5d98c57278d7ff3cc9c8d26f0/raw/after-status.png) ### Why - `start` printed progress and `Stack is ready.` only; users had to run a second command to find their URLs, and no command reported the MCP endpoint. - Bootstrap output interleaved with the spinner, e.g. `◐ Starting local Supabase stack...Seeding globals from roles.sql...`. - `status` showed 64-character stack hashes, per-service UUIDs, and `key=value` lists, but not the connection URLs or keys the legacy `status` shows. ### What changed - **Spinner-aware text output** (`shared/output/output.layer.ts`): while a task spinner is shown, logs and raw writes clear it, write on their own row, and redraw it below; overlapping writes resume it once, and a task that settles mid-write prints its final line after the write drains. A command failure drops the spinner. JSON and stream-JSON output are unchanged. - **Seed glob warnings** use `output.warn` (standard `▲` framing) instead of a hand-built `WARN:` line on stderr. - **`stack start`** prints the connection summary shared with `status`, the runtime, and a `supabase status --env` pointer that keeps any `--stack`/`--stack-id` selector. JSON adds `runtime`, `lazy_services`, and `endpoints["studio.mcp"]`; the shape is documented in `start/SIDE_EFFECTS.md` and `docs/stack-commands.md`. - **`stack status`** renders a one-line header (name, readiness, runtime, project), the legacy grouped tables (reused from `status-pretty.ts`), a services table (state, health, activation, "starts on first request"), and config drift as one muted line unless something drifted. IDs remain in JSON only. - **`status --env`** exports `MCP_URL`; `API_URL` comes from any member routed through the shared API listener, so it is still exported when REST is excluded. ### Reviewer notes - Human-readable `start`/`status` output now shows the local publishable/secret keys and the database URL, like the legacy commands; JSON results stay credential-free and `status --env` remains the machine-readable source for credentials. The docs contract is updated accordingly. - The stack gateway has no `/mcp` or `/graphql/v1` route, so MCP points at Studio's own `/api/mcp` and no GraphQL URL is advertised. - Legacy `status`/`start` pretty output is byte-identical; the renderer only gained `statusGroups`/`renderStatusGroups` exports and extra color kinds. - The seed-warning change also affects legacy local reset/start text output (`▲ no files matched …` instead of `WARN: …`). - S3 credentials are not shown because the stack Storage service does not configure S3 access keys. ## Linked issue Closes CLI-2530 Closes CLI-2546 Closes CLI-2531 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 41 +++- apps/cli/src/command-internal/colors.ts | 5 + apps/cli/src/command-internal/seed.ts | 6 +- .../src/command-internal/seed.unit.test.ts | 7 +- apps/cli/src/command-internal/shell-quote.ts | 17 ++ .../cli/src/command-internal/status-pretty.ts | 32 ++- .../cli/src/command-internal/status-values.ts | 2 +- .../db/schema/declarative/declarative.flow.ts | 26 +- .../declarative/declarative.orchestrate.ts | 2 +- .../schema/declarative/sync/sync.handler.ts | 2 +- .../experimental/stack/stack-summary.ts | 223 ++++++++++++++++++ .../experimental/stack/start/SIDE_EFFECTS.md | 16 +- .../stack/start/start.e2e.test.ts | 17 +- .../experimental/stack/start/start.handler.ts | 81 +++++-- .../stack/start/start.integration.test.ts | 61 ++++- .../experimental/stack/status/SIDE_EFFECTS.md | 22 +- .../experimental/stack/status/status.env.ts | 15 +- .../stack/status/status.env.unit.test.ts | 8 +- .../stack/status/status.handler.ts | 175 +++++++------- .../stack/status/status.integration.test.ts | 173 ++++++++++---- apps/cli/src/shared/output/output.layer.ts | 126 ++++++++-- .../shared/output/output.layer.unit.test.ts | 204 +++++++++++++++- packages/stack/src/effect.ts | 1 + packages/stack/src/host/Endpoints.ts | 1 + 24 files changed, 998 insertions(+), 265 deletions(-) create mode 100644 apps/cli/src/command-internal/shell-quote.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-summary.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index b18587fe99..059d6702c0 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -43,6 +43,41 @@ supabase stack prepare supabase stack prepare --capability rest --capability auth --output-format json ``` +## Connection details after start + +When the stack is ready, `supabase stack start` prints the API, REST, Functions, +database, Studio, MCP, and Mailpit URLs, the local publishable and secret keys, each service's +state, and the runtime. Ports are assigned per project, so read the MCP URL from this output rather +than assuming a default port. +With `--output-format json`, start returns: + +```json +{ + "id": "", + "runtime": "docker", + "endpoints": { + "database.sql": { + "protocol": "tcp", + "address": "127.0.0.1", + "port": 54322, + "url": "tcp://127.0.0.1:54322" + }, + "studio.mcp": { + "protocol": "http", + "address": "127.0.0.1", + "port": 54323, + "url": "http://127.0.0.1:54323/api/mcp" + } + }, + "lazy_services": ["rest", "auth", "studio"], + "message": "" +} +``` + +`endpoints` uses the same `service.endpoint` keys as `stack status`, and `lazy_services` lists the +services that start on their first request. For the complete connection set including credentials, +use `supabase stack status --env --output-format json`. + ## Exporting environment variables ```sh @@ -56,8 +91,10 @@ and credentials available from the observed composition; text mode emits dotenv JSON or stream-JSON mode emits a variable map. Values that are unavailable because a member is stopped or unhealthy are omitted. Add `--output-format text` for an explicit dotenv file regardless of automatic agent output detection; this is dotenv data, not a shell script, and values -are quoted so that sourcing the file performs no shell expansion. Only this -explicit export reveals credentials. Ordinary status remains free of secrets. `--override-name` +are quoted so that sourcing the file performs no shell expansion. It also exports `MCP_URL`, +Studio's MCP endpoint, whose port is assigned per project. The human-readable output of `start` and +`status` shows the local publishable and secret keys and the database URL; their JSON results +never include credentials, so this export is the machine-readable source for them. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. The DB-derived service-role JWT remains available when Auth is disabled; unavailable service URLs and credentials are omitted. diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index fe31b8f853..50addc7657 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -72,3 +72,8 @@ export function red(text: string, stream: ColorStream = process.stderr): string export function green(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("green", text, { validateStream: false }) : text; } + +/** Renders in gray for secondary text. */ +export function gray(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("gray", text, { validateStream: false }) : text; +} diff --git a/apps/cli/src/command-internal/seed.ts b/apps/cli/src/command-internal/seed.ts index 191313c707..4c5eb12396 100644 --- a/apps/cli/src/command-internal/seed.ts +++ b/apps/cli/src/command-internal/seed.ts @@ -39,8 +39,8 @@ interface PendingSeed { } // Resolves `[db.seed].sql_paths` to existing files via the shared {@link sqlFilesGlob} -// traversal, printing a single `WARN: ` line for any glob problem — unlike the -// schema-files apply path, which only warns when no pattern matched anything at all. +// traversal, warning once with all joined glob problems — unlike the schema-files apply +// path, which only warns when no pattern matched anything at all. const resolveSeedFiles = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -50,7 +50,7 @@ const resolveSeedFiles = ( Effect.gen(function* () { const output = yield* Output; const { files, warnings } = yield* sqlFilesGlob(fs, path, patterns, workdir); - if (warnings.length > 0) yield* output.raw(`WARN: ${warnings.join("\n")}\n`, "stderr"); + if (warnings.length > 0) yield* output.warn(warnings.join("\n")); return files; }); diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index 2247f26e56..1863e6919f 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -66,8 +66,11 @@ describe("applySeedFiles seed glob", () => { Effect.tap(() => Effect.sync(() => { expect(queries.some((q) => q.sql.includes("seed_files"))).toBe(false); - expect(out.rawChunks.map((c) => c.text).join("")).toContain( - "no files matched pattern: missing\\.sql", + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "no files matched pattern: missing\\.sql", + }), ); rmSync(dir, { recursive: true, force: true }); }), diff --git a/apps/cli/src/command-internal/shell-quote.ts b/apps/cli/src/command-internal/shell-quote.ts new file mode 100644 index 0000000000..44f4a56147 --- /dev/null +++ b/apps/cli/src/command-internal/shell-quote.ts @@ -0,0 +1,17 @@ +/** Shell family a printed command is rendered for. */ +export type ShellPlatform = "posix" | "windows"; + +export const currentShellPlatform = (): ShellPlatform => + process.platform === "win32" ? "windows" : "posix"; + +const BARE_SAFE_ARGUMENT = /^[a-zA-Z0-9_./:@%+=,-]+$/; + +/** Quotes one argument so a printed command can be pasted into the given shell as-is. */ +export function shellQuoteArgument(value: string, platform: ShellPlatform): string { + if (BARE_SAFE_ARGUMENT.test(value)) return value; + // PowerShell single-quoted strings escape a quote by doubling it; POSIX + // shells need the classic '"'"' dance. + return platform === "windows" + ? `'${value.replaceAll("'", "''")}'` + : `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/apps/cli/src/command-internal/status-pretty.ts b/apps/cli/src/command-internal/status-pretty.ts index d6255023d0..62ab0232d5 100644 --- a/apps/cli/src/command-internal/status-pretty.ts +++ b/apps/cli/src/command-internal/status-pretty.ts @@ -1,4 +1,4 @@ -import { aqua, bold, green, yellow } from "./colors.ts"; +import { aqua, bold, gray, green, red, yellow } from "./colors.ts"; import type { StatusOutputNames } from "./status-values.ts"; /** @@ -10,7 +10,7 @@ import type { StatusOutputNames } from "./status-values.ts"; * `process.stderr` and would check the wrong stream's TTY status. */ -type OutputKind = "text" | "link" | "key"; +type OutputKind = "text" | "link" | "key" | "good" | "pending" | "bad" | "muted"; interface OutputItem { readonly label: string; @@ -18,7 +18,8 @@ interface OutputItem { readonly kind: OutputKind; } -interface OutputGroup { +/** One rounded-border table: a title row above label/value rows. */ +export interface StatusGroup { readonly name: string; readonly items: ReadonlyArray; } @@ -29,10 +30,10 @@ const COLUMN_0_MAX_WIDTH = 16; * Builds the 5 fixed display groups, looking up each label's value by its resolved output * key — `--override-name` remaps the key but never the group layout. */ -function buildGroups( +export function statusGroups( values: Readonly>, names: StatusOutputNames, -): ReadonlyArray { +): ReadonlyArray { const at = (key: string) => values[key] ?? ""; return [ { @@ -77,7 +78,7 @@ function buildGroups( /** * Display width for this command's inputs: URLs/keys/labels are always plain ASCII, so every - * rune is width 1. The 5 fixed group-title emoji are the only non-ASCII runes ever rendered, + * rune is width 1. The fixed group-title emoji are the only non-ASCII runes ever rendered, * and their widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} instead of computed * generically. */ @@ -92,11 +93,12 @@ const HEADER_DISPLAY_WIDTH: Readonly> = { "⛁ Database": 10, "🔑 Authentication Keys": 22, "📦 Storage (S3)": 15, + "🧩 Services": 11, }; /** * Exported only for direct unit coverage of the fallback branch — every call site in this - * file passes one of the 5 fixed titles in {@link HEADER_DISPLAY_WIDTH}. + * file passes one of the fixed titles in {@link HEADER_DISPLAY_WIDTH}. */ export function statusHeaderWidth(name: string): number { return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); @@ -125,13 +127,19 @@ export function wrapStatusLabel(text: string, width: number): ReadonlyArray 0 ? lines : [text]; } -/** Value coloring: `link` → aqua, `key` → yellow, `text` → unstyled. */ function colorValue(kind: OutputKind, value: string): string { switch (kind) { case "link": return aqua(value, process.stdout); case "key": + case "pending": return yellow(value, process.stdout); + case "good": + return green(value, process.stdout); + case "bad": + return red(value, process.stdout); + case "muted": + return gray(value, process.stdout); case "text": return value; } @@ -173,7 +181,7 @@ export function statusColumnLayout( return { col0Padded, col1Padded, targetInner }; } -function renderGroupTable(group: OutputGroup): string | undefined { +function renderGroupTable(group: StatusGroup): string | undefined { const rows = group.items.filter((item) => item.value.length > 0); if (rows.length === 0) return undefined; @@ -229,7 +237,11 @@ export function renderStatusPretty( values: Readonly>, names: StatusOutputNames, ): string { - const groups = buildGroups(values, names); + return renderStatusGroups(statusGroups(values, names)); +} + +/** Renders groups as rounded-border tables, followed by one blank line per group. */ +export function renderStatusGroups(groups: ReadonlyArray): string { const lines: string[] = []; for (const group of groups) { const table = renderGroupTable(group); diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index fa3ad6a9f0..9c5c430a62 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -102,7 +102,7 @@ export interface StatusOutputNames { * Resolves each field's output key, applying `--override-name =` remaps over * the default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) to the replacement name. */ -function resolveOutputNames(overrides: ReadonlyMap): StatusOutputNames { +export function resolveOutputNames(overrides: ReadonlyMap): StatusOutputNames { const nameFor = (field: StatusField) => overrides.get(field.fieldKey) ?? field.defaultName; return { apiUrl: nameFor(API_URL), diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts index e3b9301104..239c3c55fc 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -1,4 +1,8 @@ import { schemaToCsvField } from "../../../../command-internal/schema-flags.ts"; +import { + shellQuoteArgument, + type ShellPlatform, +} from "../../../../command-internal/shell-quote.ts"; import { declaredSqlExtensions, maskSqlComments, @@ -226,19 +230,10 @@ export function classifyDeclarativeLoadCompatibility(opts: { export const extensionDeclaration = (extension: string): string => `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";`; -/** - * Shell family the recovery commands are rendered for: POSIX gets `rm -rf`/`mv` with `&&` and - * backslash continuations; Windows gets single-line PowerShell (`Remove-Item`/`Move-Item` with - * `;`), since the staged-upgrade recipe must be runnable exactly as printed. - */ -export type ShellPlatform = "posix" | "windows"; - -export const currentShellPlatform = (): ShellPlatform => - process.platform === "win32" ? "windows" : "posix"; - export interface StagedExportContext { readonly declarativeDir: string; readonly schema: ReadonlyArray; + /** POSIX gets `rm -rf`/`mv` chains; Windows gets single-line PowerShell, runnable as printed. */ readonly platform: ShellPlatform; } @@ -264,17 +259,6 @@ export const resolveStagedDeclarativeDir = (declarativeDir: string): string => { return `${trimmed === "" ? declarativeDir : trimmed}-next`; }; -const BARE_SAFE_ARGUMENT = /^[a-zA-Z0-9_./:@%+=,-]+$/; - -function shellQuoteArgument(value: string, platform: ShellPlatform): string { - if (BARE_SAFE_ARGUMENT.test(value)) return value; - // PowerShell single-quoted strings escape a quote by doubling it; POSIX - // shells need the classic '"'"' dance. - return platform === "windows" - ? `'${value.replaceAll("'", "''")}'` - : `'${value.replaceAll("'", `'"'"'`)}'`; -} - function schemaArguments(schema: ReadonlyArray, platform: ShellPlatform): string { return schema .map((name) => ` --schema ${shellQuoteArgument(schemaToCsvField(name), platform)}`) diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts index bd4ea52e14..5d2de28330 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -13,11 +13,11 @@ import { LoadPgDeltaSqlFiles, ReadPgDeltaExportManifest } from "../../shared/pgd import { DeclarativeCompatibilityError, DeclarativeDiffError } from "./declarative.errors.ts"; import { classifyDeclarativeLoadCompatibility, - currentShellPlatform, formatDeclarativeUpgradeGate, type DeclarativeLoadCompatibilityFinding, type DeclarativeUpgradeGateText, } from "./declarative.flow.ts"; +import { currentShellPlatform } from "../../../../command-internal/shell-quote.ts"; /** Ambient inputs shared by the orchestration steps. */ export interface DeclarativeRunContext { diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 37a2a84c0c..cdbd06e22a 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -54,7 +54,6 @@ import { } from "../declarative.errors.ts"; import { classifyDeclarativeCompatibilityGap, - currentShellPlatform, formatDeclarativeGapEvidence, formatDeclarativeUpgradeGate, formatStagedExportAdoption, @@ -62,6 +61,7 @@ import { resolveDeclarativeMigrationName, resolveDeclarativeSyncApplyDecision, } from "../declarative.flow.ts"; +import { currentShellPlatform } from "../../../../../command-internal/shell-quote.ts"; import { warnFormerDeclarativeDefault } from "../declarative.former-default.ts"; import { appendExtensionDeclarations } from "../declarative.extension-repair.ts"; import { requirePgDelta } from "../declarative.gate.ts"; diff --git a/apps/cli/src/commands/experimental/stack/stack-summary.ts b/apps/cli/src/commands/experimental/stack/stack-summary.ts new file mode 100644 index 0000000000..88bfe01b7c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-summary.ts @@ -0,0 +1,223 @@ +import { Effect, Redacted } from "effect"; +import { + apiRoute, + type Observation, + type ServiceCreation, + type StackCredentials, +} from "@supabase/stack/effect"; +import { red } from "../../../command-internal/colors.ts"; +import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; +import { + renderStatusGroups, + statusGroups, + type StatusGroup, +} from "../../../command-internal/status-pretty.ts"; +import { resolveOutputNames } from "../../../command-internal/status-values.ts"; +import { endpointReports } from "./stack-endpoints.format.ts"; + +type ServiceName = ServiceCreation["service"]; + +export type StackServiceState = + | "unavailable" + | "sleeping" + | "starting" + | "running" + | "stopping" + | "stopped" + | "unhealthy" + | "exited"; + +/** An observed service; `activation` is undefined for an instance outside the composition. */ +export interface StackServiceView { + readonly service: ServiceName; + readonly observation: Observation | undefined; + readonly activation: string | undefined; + readonly error?: string | undefined; +} + +/** Connection URLs derived from the observed composition members. */ +export interface StackConnections { + readonly api?: string; + readonly rest?: string; + readonly functions?: string; + readonly studio?: string; + readonly mcp?: string; + readonly mailpit?: string; + readonly database?: string; +} + +export const serviceState = (observation: Observation | undefined): StackServiceState => { + if (observation === undefined) return "unavailable"; + if (observation.health === "unhealthy") return "unhealthy"; + if (observation.lifecycle === "stopped") { + if (observation.error?.operation === "exit") return "exited"; + return observation.wakeEnabled ? "sleeping" : "stopped"; + } + return observation.lifecycle; +}; + +// Studio serves MCP itself; the stack gateway has no `/mcp` route. +const mcpUrl = (studioUrl: string) => `${studioUrl}/api/mcp`; + +/** Reports endpoints keyed `service.endpoint`, plus `studio.mcp` when Studio has an HTTP endpoint. */ +export const stackEndpoints = ( + services: ReadonlyArray>, +) => { + const endpoints = Object.fromEntries( + services.flatMap(({ service, observation }) => + Object.entries(endpointReports(observation)).map( + ([name, endpoint]) => [`${service}.${name}`, endpoint] as const, + ), + ), + ); + const studio = endpoints["studio.http"]; + return studio === undefined + ? endpoints + : { ...endpoints, "studio.mcp": { ...studio, url: mcpUrl(studio.url) } }; +}; + +const stackDatabaseUrl = (observation: Observation | undefined): string | undefined => { + if (observation?.config.service !== "database") return undefined; + const sql = observation.endpoints.find(({ name }) => name === "sql"); + return sql === undefined + ? undefined + : toPostgresURL({ + host: sql.host, + port: sql.port, + user: "supabase_admin", + password: Redacted.value(observation.config.config.databasePassword), + database: "postgres", + }); +}; + +/** Derives connection URLs; callers pass composition members only. */ +export const stackConnections = ( + members: ReadonlyArray>, +): StackConnections => { + const http = (services: ReadonlyArray) => + members + .filter(({ service }) => services.includes(service)) + .map(({ observation }) => endpointReports(observation).http?.url) + .find((url) => url !== undefined); + const api = http( + members.map(({ service }) => service).filter((service) => apiRoute(service) !== undefined), + ); + const routed = (service: ServiceName) => { + const route = apiRoute(service); + return api === undefined || route === undefined || http([service]) === undefined + ? undefined + : `${api}${route}`; + }; + const rest = routed("rest"); + const functions = routed("functions"); + const studio = http(["studio"]); + const mailpit = http(["mail"]); + const database = stackDatabaseUrl( + members.find(({ service }) => service === "database")?.observation, + ); + return { + ...(api === undefined ? {} : { api }), + ...(rest === undefined ? {} : { rest }), + ...(functions === undefined ? {} : { functions }), + ...(studio === undefined ? {} : { studio, mcp: mcpUrl(studio) }), + ...(mailpit === undefined ? {} : { mailpit }), + ...(database === undefined ? {} : { database }), + }; +}; + +const connectionValues = ( + connections: StackConnections, + credentials: Pick | undefined, +) => { + const names = resolveOutputNames(new Map()); + const entries: ReadonlyArray = [ + [names.apiUrl, connections.api], + [names.restUrl, connections.rest], + [names.functionsUrl, connections.functions], + [names.studioUrl, connections.studio], + [names.mcpUrl, connections.mcp], + [names.mailpitUrl, connections.mailpit], + [names.dbUrl, connections.database], + [names.publishableKey, credentials?.publishableKey], + [names.secretKey, credentials?.secretKey], + ]; + return { + names, + values: Object.fromEntries( + entries.filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ), + }; +}; + +const serviceDetail = (view: StackServiceView, state: StackServiceState) => { + const health = view.observation?.health; + const parts: Array = [state]; + if (state === "running" && health !== undefined) parts.push(health); + if (view.activation === undefined) parts.push("standalone"); + else if (view.activation === "lazy" && state === "sleeping") + parts.push("starts on first request"); + else parts.push(view.activation); + return parts.join(" · "); +}; + +const serviceKind = (view: StackServiceView, state: StackServiceState) => { + switch (state) { + case "running": + return view.observation?.health === "healthy" ? "good" : "pending"; + case "starting": + case "stopping": + return "pending"; + case "unhealthy": + case "exited": + return "bad"; + case "sleeping": + case "stopped": + case "unavailable": + return "muted"; + } +}; + +const servicesGroup = (services: ReadonlyArray): StatusGroup => ({ + name: "🧩 Services", + items: services.map((view) => { + const state = serviceState(view.observation); + return { + label: view.service, + value: serviceDetail(view, state), + kind: serviceKind(view, state), + }; + }), +}); + +/** Renders the member connections and service states in the legacy `status` table layout. */ +export const renderStackSummary = ( + services: ReadonlyArray, + credentials: Pick | undefined, +): string => { + const { values, names } = connectionValues( + stackConnections(services.filter(({ activation }) => activation !== undefined)), + credentials, + ); + const errors = services.flatMap(({ service, error }) => + error === undefined ? [] : [red(`${service}: ${error}`, process.stdout)], + ); + const groups = renderStatusGroups( + [...statusGroups(values, names), servicesGroup(services)].filter(({ items }) => + items.some(({ value }) => value.length > 0), + ), + ); + return errors.length === 0 ? groups : `${groups}${errors.join("\n")}\n\n`; +}; + +/** Reads saved keys for display, warning instead of failing when they cannot be read. */ +export const summaryCredentials = ( + read: Effect.Effect, + warn: (message: string) => Effect.Effect, +) => + read.pipe( + Effect.catch((error) => + warn( + `The stack keys could not be read: ${error.message}. Run supabase status --env to retry.`, + ).pipe(Effect.as(undefined)), + ), + ); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 9eabf076fd..159f0e1ecb 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -119,7 +119,15 @@ It remains available after the CLI exits. Preparation downloads native artifacts images. Catalog setup and project SQL connect to the primary database. Bucket seeding uses the local Storage HTTP endpoint. The CLI does not remove caller-owned Storage files during cleanup. -Text output reports progress and `Stack is ready.`. JSON output returns the stack `id`, assigned `endpoints` keyed by service and endpoint name -(for example `database.sql`), and an empty message. Endpoints contain protocol, address, port, -and URL, matching `stack status`; use status for service observations. Failures retain typed command -errors and package diagnostics. Telemetry state is flushed after success or failure. +Text output reports progress and `Stack is ready.`, then prints the connection summary shared with +`stack status` on stdout: API, REST, Functions, Studio, MCP, Mailpit, and database URLs for the +members that expose them, the publishable and secret keys, a services table, the runtime, and a +pointer to `supabase status --env` that repeats an explicit `--workdir` and any `--stack` or `--stack-id` selector, shell-quoted. Progress lines +and warnings written while the spinner is shown appear on their own rows. + +JSON output returns the stack `id`, its saved `runtime`, `endpoints` keyed by service and endpoint +name (protocol, address, port, and URL, matching `stack status`, plus `studio.mcp` when Studio has +an HTTP endpoint), `lazy_services` listing members that start on their first request (empty with +`--eager`), and an empty message. See [`docs/stack-commands.md`](../../../../../docs/stack-commands.md) +for an example. Credentials are not part of the JSON result. Failures retain typed command errors +and package diagnostics. Telemetry state is flushed after success or failure. diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index f09f1988fa..acdc7b2009 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -425,11 +425,9 @@ describe("stack start (compiled e2e)", () => { exitTimeoutMs: CLEANUP_TIMEOUT_MS, }); expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); - expect(status.stdout).toContain(`(${idText})`); - expect(status.stdout).toContain("Owner: reachable"); - expect(status.stdout).toContain("Lifecycle: running"); - expect(status.stdout).toContain("Readiness: ready"); - expect(status.stdout).toMatch(/Config drift: (changed|unchanged)/u); + expect(status.stdout).toContain(" · ready · native · "); + expect(status.stdout).toMatch(/database +│ running · healthy · eager +│/u); + expect(status.stdout).not.toContain(idText); const topLevelStatus = yield* runSupabaseEffect(["status", "--stack-id", idText], { cwd: projectRoot, @@ -441,9 +439,7 @@ describe("stack start (compiled e2e)", () => { topLevelStatus.exitCode, `stdout:\n${topLevelStatus.stdout}\nstderr:\n${topLevelStatus.stderr}`, ).toBe(0); - expect(topLevelStatus.stdout).toContain(`(${idText})`); - expect(topLevelStatus.stdout).toContain("Owner: reachable"); - expect(topLevelStatus.stdout).toContain("Lifecycle: running"); + expect(topLevelStatus.stdout).toContain(" · ready · native · "); const env = yield* runSupabaseEffect( ["stack", "status", "--env", "--stack-id", idText, "--output-format", "json"], @@ -508,9 +504,8 @@ describe("stack start (compiled e2e)", () => { stoppedStatus.exitCode, `stdout:\n${stoppedStatus.stdout}\nstderr:\n${stoppedStatus.stderr}`, ).toBe(0); - expect(stoppedStatus.stdout).toContain("Owner: unavailable"); - expect(stoppedStatus.stdout).toContain("Lifecycle: unavailable"); - expect(stoppedStatus.stdout).toContain("Readiness: unavailable"); + expect(stoppedStatus.stdout).toContain(" · unavailable · native · "); + expect(stoppedStatus.stdout).toContain("The stack owner is not running."); const stoppedEnv = yield* runSupabaseEffect( ["stack", "status", "--env", "--stack-id", idText], diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index e590ff0572..3bebe33d51 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,5 +1,15 @@ import { defaultRuntime } from "@supabase/stack/internal/artifacts"; -import { endpointReports } from "../stack-endpoints.format.ts"; +import { + renderStackSummary, + stackEndpoints, + summaryCredentials, + type StackServiceView, +} from "../stack-summary.ts"; +import { gray } from "../../../../command-internal/colors.ts"; +import { + currentShellPlatform, + shellQuoteArgument, +} from "../../../../command-internal/shell-quote.ts"; import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; import { automaticRuntimeNotice, @@ -11,7 +21,9 @@ import { resolveNativePostgresUser, type Observation, type PlannedInstance, + type ServiceCreation, type ServiceCreationInput, + type Stack, type StackError, } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -182,22 +194,28 @@ const selectedCreations = ( const isServing = (status: Pick) => status.lifecycle === "running" && status.health === "healthy"; -const reportEndpoints = ( - members: ReadonlyArray<{ - readonly service: string; +const startReport = ( + stack: Pick, + instances: ReadonlyArray<{ + readonly id: string; + readonly service: ServiceCreation["service"]; readonly status: Effect.Effect; }>, ) => - Effect.forEach(members, (member) => - member.status.pipe( - Effect.mapError(stackError), - Effect.map((observation) => - Object.entries(endpointReports(observation)).map( - ([name, endpoint]) => [`${member.service}.${name}`, endpoint] as const, - ), + Effect.gen(function* () { + const { members } = yield* stack.composition.describe; + const activation = new Map(members.map((member) => [member.id, member.activation])); + const views = yield* Effect.forEach(instances, (instance) => + instance.status.pipe( + Effect.map((observation): StackServiceView => ({ + service: instance.service, + observation, + activation: activation.get(instance.id), + })), ), - ), - ).pipe(Effect.map((entries) => Object.fromEntries(entries.flat()))); + ); + return { views, endpoints: stackEndpoints(views) }; + }).pipe(Effect.mapError(stackError)); /** Starts the selected managed stack and applies the local database overlays. */ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags: StackStartFlags) { @@ -284,6 +302,31 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags stackAcquireError(cause, { selectedRuntime, runtime, creating: target.id === undefined }), ), ); + const platform = currentShellPlatform(); + const selector = [ + ...(settings.explicitWorkdir ? ["--workdir", target.projectRoot] : []), + ...(Option.isSome(flags.stack) ? ["--stack", flags.stack.value] : []), + ...(Option.isSome(flags.stackId) ? ["--stack-id", flags.stackId.value] : []), + ] + .map((argument) => ` ${shellQuoteArgument(argument, platform)}`) + .join(""); + const reportReady = (report: Effect.Success>, message: string) => + Effect.gen(function* () { + if (output.format !== "text") + return yield* output.success(message, { + id: stack.id, + runtime: selectedRuntime, + endpoints: report.endpoints, + lazy_services: report.views + .filter(({ activation }) => activation === "lazy") + .map(({ service }) => service), + }); + if (message.length > 0) yield* output.success(message); + const credentials = yield* summaryCredentials(stack.credentials.get, output.warn); + yield* output.raw( + `\n${renderStackSummary(report.views, credentials)}\n${gray(`Runtime: ${selectedRuntime}`, process.stdout)}\nRun supabase status --env${selector} to export these values as environment variables.\n`, + ); + }); const runtimeNotice = target.id === undefined ? automaticRuntimeNotice(target.runtime, selectedRuntime) : undefined; if (runtimeNotice !== undefined) yield* output.info(runtimeNotice); @@ -307,9 +350,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ); if (fullyStarted) { yield* Ref.set(startupComplete, true); - yield* output.success( + yield* reportReady( + yield* startReport(stack, currentInstances), "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", - { id: stack.id, endpoints: yield* reportEndpoints(currentInstances) }, ); return stack.id; } @@ -348,11 +391,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags Effect.mapError(stackError), ); yield* Ref.set(startupComplete, true); - const endpoints = yield* reportEndpoints(currentInstances).pipe( + const report = yield* startReport(stack, currentInstances).pipe( Effect.tapError((error) => starting.fail(error.message)), ); yield* starting.succeed("Stack is ready."); - yield* output.success("", { id: stack.id, endpoints }); + yield* reportReady(report, ""); return stack.id; } const fullyStopped = currentStatuses.every( @@ -631,11 +674,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ); yield* Effect.forEach(preparation, (fiber) => Fiber.join(fiber)); yield* Ref.set(startupComplete, true); - const endpoints = yield* reportEndpoints(members).pipe( + const report = yield* startReport(stack, members).pipe( Effect.tapError((error) => starting.fail(error.message)), ); yield* starting.succeed("Stack is ready."); - yield* output.success("", { id: stack.id, endpoints }); + yield* reportReady(report, ""); return stack.id; }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 0e9a760b1b..b04eaa8b21 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -87,7 +87,16 @@ const instance = ( endpoints: config.service === "database" ? [{ name: "sql", protocol: "tcp" as const, host: "127.0.0.1", port: 23456 }] - : [], + : config.service === "rest" || config.service === "studio" + ? [ + { + name: "http", + protocol: "http" as const, + host: "127.0.0.1", + port: config.service === "rest" ? 23457 : 23458, + }, + ] + : [], config, lifecycle: lifecycle(), health: health(), @@ -288,7 +297,12 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { () => memberReadiness.get(id) ?? Effect.void, ); }); - activations = new Map(members.map(({ id }) => [id, "eager"])); + activations = new Map( + members.map(({ id, service }) => [ + id, + options?.eager === true || service === "database" ? "eager" : "lazy", + ]), + ); return members; }), plan: (creations) => @@ -386,6 +400,7 @@ const layers = ( fixture: ReturnType, output = mockOutput(), existing = true, + explicitWorkdir = false, ) => { const telemetry = mockTelemetryStateTracked(); const target = Layer.succeed(StackTargetResolver, { @@ -408,7 +423,7 @@ const layers = ( runtimeInfoLayer, output.layer, telemetry.layer, - mockCommandSettings({ workdir: root }), + mockCommandSettings({ workdir: root, explicitWorkdir }), target, api, Layer.succeed(ExperimentalFlag, false), @@ -507,6 +522,7 @@ describe("experimental stack start", () => { expect.objectContaining({ data: { id: fixture.stack.id, + runtime: "native", endpoints: { "database.sql": { protocol: "tcp", @@ -515,6 +531,7 @@ describe("experimental stack start", () => { url: "tcp://127.0.0.1:23456", }, }, + lazy_services: [], }, }), ); @@ -552,6 +569,44 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("reports connection details and lazy services once the stack is ready", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-summary-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "start-summary"\n[edge_runtime]\nenabled = false\n', + ); + const excluded = ["auth", "realtime", "storage", "functions", "mail", "analytics", "pooler"]; + const fixture = fakeStack(); + const json = mockOutput({ format: "json" }); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); + expect(json.messages.find(({ data }) => data !== undefined)?.data).toMatchObject({ + runtime: "native", + endpoints: { + "rest.http": { url: "http://127.0.0.1:23457" }, + "studio.mcp": { port: 23458, url: "http://127.0.0.1:23458/api/mcp" }, + }, + lazy_services: ["pgmeta", "rest", "studio"], + }); + + yield* fixture.stack.composition.stop; + const text = mockOutput(); + yield* stackStart({ ...flags(excluded), stack: Option.some("feature demo") }).pipe( + Effect.provide(layers(root, fixture, text, true, true)), + ); + expect(text.stdoutText).toMatch(/Project URL +│ http:\/\/127\.0\.0\.1:23457 +│/u); + expect(text.stdoutText).toMatch(/MCP +│ http:\/\/127\.0\.0\.1:23458\/api\/mcp +│/u); + expect(text.stdoutText).not.toContain("GraphQL"); + expect(text.stdoutText).toMatch(/Secret +│ \S+ +│/u); + expect(text.stdoutText).toMatch(/rest +│ running · healthy · lazy +│/u); + expect(text.stdoutText).toContain( + `Runtime: native\nRun supabase status --env --workdir ${root} --stack 'feature demo' to export these values as environment variables.\n`, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("names the services that failed when the composition start fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index 90d243b0db..ac8129aaac 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -33,17 +33,27 @@ loading failure or unreadable saved state keeps the saved stack report available saved standalone instances; composition members identify the services used for primary database, environment export, and drift comparisons. -Text output includes identity, runtime, owner, lifecycle, readiness, services, -endpoints, and config drift. JSON nests only identity fields under `identity`; -runtime, lifecycle, readiness, composition, services, endpoints, and config -drift remain top-level fields. Stack identity, endpoints, and credentials are -never telemetry properties. +Text output starts with one line naming the stack, its readiness, runtime, and +project directory, noting when the owner is unavailable. It then prints the +connection summary shared with `stack start`: the API, REST, Functions, +Studio, MCP, Mailpit, and database URLs that the composition members expose, the +saved publishable and secret keys, and a services table with each service's +state, health, and activation; sleeping lazy services are marked as starting on +first request. Service errors follow the tables, and config drift ends the +output as one muted line unless the configuration drifted. Stack and service IDs appear only +in JSON. JSON nests only identity fields under `identity`; runtime, lifecycle, +readiness, composition, services, endpoints, and config drift remain top-level +fields. `endpoints` also carries `studio.mcp`, Studio's MCP URL, when Studio is +present. Stack identity, endpoints, and credentials are never telemetry +properties. ## Exporting environment variables (`--env`) `--env` is the explicit environment-export operation. It requires a reachable owner and a running primary database, then derives the database URL from the -observed SQL endpoint and saved database credentials, using the `supabase_admin` role. It does not request live +observed SQL endpoint and saved database credentials, using the `supabase_admin` role. +`API_URL` is the shared API listener of any member routed through it, and +`MCP_URL` is Studio's MCP endpoint. It does not request live credentials or launch an owner, and it does not load or compare project configuration. Text output emits dotenv assignments; JSON and stream-JSON output emit a plain variable map under a successful result. diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 82bff7a0de..0a5b630eca 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -1,5 +1,6 @@ import { Effect } from "effect"; import type { StackCredentials } from "@supabase/stack/effect"; +import type { StackConnections } from "../stack-summary.ts"; import { StackCommandStatusError } from "./status.errors.ts"; const variableNames = [ @@ -11,6 +12,7 @@ const variableNames = [ "SECRET_KEY", "STUDIO_URL", "INBUCKET_URL", + "MCP_URL", "S3_PROTOCOL_ACCESS_KEY_ID", "S3_PROTOCOL_ACCESS_KEY_SECRET", "S3_PROTOCOL_REGION", @@ -53,11 +55,7 @@ export const stackEnvOverrides = (entries: ReadonlyArray) => export const stackEnvValues = ( status: { - readonly endpoints: Readonly<{ - readonly api?: { readonly url: string }; - readonly studio?: { readonly url: string }; - readonly mailUi?: { readonly url: string }; - }>; + readonly urls: Pick; readonly credentials?: Pick< StackCredentials, "publishableKey" | "secretKey" | "anonKey" | "serviceRoleKey" @@ -74,9 +72,10 @@ export const stackEnvValues = ( values.PUBLISHABLE_KEY = status.credentials.publishableKey; values.SECRET_KEY = status.credentials.secretKey; } - if (status.endpoints.api !== undefined) values.API_URL = status.endpoints.api.url; - if (status.endpoints.studio !== undefined) values.STUDIO_URL = status.endpoints.studio.url; - if (status.endpoints.mailUi !== undefined) values.INBUCKET_URL = status.endpoints.mailUi.url; + if (status.urls.api !== undefined) values.API_URL = status.urls.api; + if (status.urls.studio !== undefined) values.STUDIO_URL = status.urls.studio; + if (status.urls.mcp !== undefined) values.MCP_URL = status.urls.mcp; + if (status.urls.mailpit !== undefined) values.INBUCKET_URL = status.urls.mailpit; return Object.fromEntries( Object.entries(values).map(([key, value]) => [names.get(key) ?? key, value]), ); diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index d1706e90e0..587a4cc2cd 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -47,7 +47,7 @@ describe("stack environment overrides", () => { const names = yield* stackEnvOverrides([]); const values = stackEnvValues( { - endpoints: {}, + urls: {}, credentials: { publishableKey: "sb_publishable_saved", secretKey: "sb_secret_saved", @@ -71,9 +71,9 @@ describe("stack environment overrides", () => { Effect.gen(function* () { const names = yield* stackEnvOverrides(["API_URL=NEXT_PUBLIC_API_URL"]); expect(names.get("API_URL")).toBe("NEXT_PUBLIC_API_URL"); - expect( - stackEnvValues({ endpoints: { api: { url: "http://127.0.0.1:54321" } } }, {}, names), - ).toEqual({ NEXT_PUBLIC_API_URL: "http://127.0.0.1:54321" }); + expect(stackEnvValues({ urls: { api: "http://127.0.0.1:54321" } }, {}, names)).toEqual({ + NEXT_PUBLIC_API_URL: "http://127.0.0.1:54321", + }); for (const entries of [ ["UNKNOWN=value"], diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index b89ed5717a..f3490e8f67 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -1,14 +1,22 @@ import { endpointReports } from "../stack-endpoints.format.ts"; -import { Effect, Option, Path, Redacted } from "effect"; +import { Effect, Option, Path } from "effect"; import type { Observation, PlannedInstance, ServiceCreation, Stack } from "@supabase/stack/effect"; -import type { StackError } from "@supabase/stack/effect"; +import type { StackCredentials, StackError } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; -import { toPostgresURL } from "../../../../command-internal/postgres-url.ts"; +import { bold, gray, green, red, yellow } from "../../../../command-internal/colors.ts"; +import { + renderStackSummary, + serviceState, + stackConnections, + type StackServiceState, + stackEndpoints, + summaryCredentials, +} from "../stack-summary.ts"; import { StackApi, StackTargetError, @@ -30,15 +38,7 @@ type EndpointReport = { type ServiceReport = { readonly id: string; readonly service: ServiceCreation["service"]; - readonly state: - | "unavailable" - | "sleeping" - | "starting" - | "running" - | "stopping" - | "stopped" - | "unhealthy" - | "exited"; + readonly state: StackServiceState; readonly lifecycle: Observation["lifecycle"] | null; readonly health: Observation["health"] | null; readonly endpoints: Readonly>; @@ -105,16 +105,6 @@ const mapStackError = (error: StackError) => cause: error, }); -const serviceState = (observation: Observation | undefined): ServiceReport["state"] => { - if (observation === undefined) return "unavailable"; - if (observation.health === "unhealthy") return "unhealthy"; - if (observation.lifecycle === "stopped") { - if (observation.error?.operation === "exit") return "exited"; - return observation.wakeEnabled ? "sleeping" : "stopped"; - } - return observation.lifecycle; -}; - const serviceReport = ({ instance, observation, error }: ObservedService): ServiceReport => ({ id: instance.id, service: instance.service, @@ -155,18 +145,6 @@ const aggregateReadiness = ( : "starting"; }; -const endpointFor = ( - services: ReadonlyArray, - members: ReadonlyArray<{ readonly id: string }>, - service: ServiceCreation["service"], - endpoint: string, -) => { - const memberIds = new Set(members.map(({ id }) => id)); - return services.find((entry) => memberIds.has(entry.id) && entry.service === service)?.endpoints[ - endpoint - ]; -}; - const reportFor = ( definition: { readonly id: string; @@ -183,13 +161,8 @@ const reportFor = ( configDrift: StackReport["config_drift"], ): StackReport => { const services = observed.map(serviceReport); - const endpoints = Object.fromEntries( - services.flatMap((service) => - Object.entries(service.endpoints).map(([name, endpoint]) => [ - `${service.service}.${name}`, - endpoint, - ]), - ), + const endpoints = stackEndpoints( + observed.map(({ instance, observation }) => ({ service: instance.service, observation })), ); return { identity: { @@ -221,32 +194,55 @@ const reportFor = ( }; }; -const render = (report: StackReport): string => { - const lines = [ - `Stack ${report.identity.name} (${report.identity.id})`, - `Project: ${report.identity.project_root}`, - `Branch: ${report.identity.branch_context}`, - `Runtime: ${report.runtime}`, - `Owner: ${report.owner}`, - `Lifecycle: ${report.lifecycle ?? "unavailable"}`, - `Readiness: ${report.readiness}`, - "Services:", - ]; - const members = new Map(report.composition.members.map((member) => [member.id, member])); - for (const service of report.services) { - const details = [service.state, `lifecycle=${service.lifecycle ?? "unavailable"}`]; - const member = members.get(service.id); - details.push(member === undefined ? "standalone" : `activation=${member.activation}`); - if (service.health !== null) details.push(`health=${service.health}`); - lines.push(` ${service.service} (${service.id}): ${details.join(", ")}`); - for (const [name, endpoint] of Object.entries(service.endpoints)) - lines.push(` ${name}: ${endpoint.url}`); - if (service.error !== undefined) lines.push(` error: ${service.error}`); +const readinessColor = (readiness: StackReport["readiness"]) => { + switch (readiness) { + case "ready": + return green(readiness, process.stdout); + case "starting": + return yellow(readiness, process.stdout); + case "unhealthy": + return red(readiness, process.stdout); + case "sleeping": + case "stopped": + case "unavailable": + return gray(readiness, process.stdout); } - lines.push(`Config drift: ${report.config_drift.status}`); - lines.push(` ${report.config_drift.message}`); - for (const path of report.config_drift.paths ?? []) lines.push(` ${path}`); - return `${lines.join("\n")}\n`; +}; + +const renderDrift = (drift: StackReport["config_drift"]): ReadonlyArray => + drift.status === "changed" + ? [ + yellow(drift.message, process.stdout), + ...(drift.paths ?? []).map((path) => ` ${path}`), + gray( + "Run supabase stack stop, then supabase stack start to apply the changes.", + process.stdout, + ), + ] + : [gray(drift.message, process.stdout)]; + +const render = ( + report: StackReport, + observed: ReadonlyArray, + members: ReadonlyArray<{ readonly id: string; readonly activation: string }>, + credentials: StackCredentials | undefined, +): string => { + const activation = new Map(members.map((member) => [member.id, member.activation])); + const header = `${bold(`Stack ${report.identity.name}`, process.stdout)} · ${readinessColor(report.readiness)} · ${report.runtime} · ${gray(report.identity.project_root, process.stdout)}`; + const owner = + report.owner === "unavailable" + ? [gray("The stack owner is not running. Run supabase stack start.", process.stdout)] + : []; + const summary = renderStackSummary( + observed.map(({ instance, observation, error }) => ({ + service: instance.service, + observation, + activation: activation.get(instance.id), + error: error?.message ?? observation?.error?.message, + })), + credentials, + ); + return `${[header, ...owner].join("\n")}\n\n${summary}\n${renderDrift(report.config_drift).join("\n")}\n`; }; const findTarget = Effect.fn("experimental.stack.status.findTarget")(function* ( @@ -307,7 +303,7 @@ const driftFrom = (planned: ReadonlyArray): StackReport["config } : { status: "changed", - message: `${paths.length} configured service value${paths.length === 1 ? "" : "s"} differ from the saved stack.`, + message: `${paths.length} configured service ${paths.length === 1 ? "value differs" : "values differ"} from the saved stack.`, paths, }; }; @@ -404,8 +400,7 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( message: "The stack owner or primary database is unavailable for environment export.", suggestion: "Run supabase stack start first.", }); - const databaseConfig = databaseObservation.config; - if (databaseConfig.service !== "database") + if (databaseObservation.config.service !== "database") return yield* new StackCommandStatusError({ reason: "lifecycle", message: "The primary database configuration is unavailable for environment export.", @@ -418,32 +413,14 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( message: "The stack's active credentials are unavailable for environment export.", suggestion: "Run supabase stack start first.", }); - const sql = databaseObservation.endpoints.find(({ name }) => name === "sql"); - const databaseUrl = - sql === undefined - ? undefined - : toPostgresURL({ - host: sql.host, - port: sql.port, - user: "supabase_admin", - password: Redacted.value(databaseConfig.config.databasePassword), - database: "postgres", - }); - const services = observed.observed.map(serviceReport); - const endpoints = { - ...(endpointFor(services, observed.members, "rest", "http") === undefined - ? {} - : { api: endpointFor(services, observed.members, "rest", "http") }), - ...(endpointFor(services, observed.members, "studio", "http") === undefined - ? {} - : { studio: endpointFor(services, observed.members, "studio", "http") }), - ...(endpointFor(services, observed.members, "mail", "http") === undefined - ? {} - : { mailUi: endpointFor(services, observed.members, "mail", "http") }), - }; + const connections = stackConnections( + observed.observed.flatMap(({ instance, observation }) => + memberIds.has(instance.id) ? [{ service: instance.service, observation }] : [], + ), + ); const values = stackEnvValues( - { endpoints, credentials: identity }, - databaseUrl === undefined ? {} : { databaseUrl }, + { urls: connections, credentials: identity }, + connections.database === undefined ? {} : { databaseUrl: connections.database }, envNames, ); if (output.format === "text") yield* output.raw(yield* encodeStackEnv(values)); @@ -464,7 +441,15 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( observed.members, config, ); - if (output.format === "text") yield* output.raw(render(report)); + if (output.format === "text") + yield* output.raw( + render( + report, + observed.observed, + observed.members, + yield* summaryCredentials(stack.credentials.get, output.warn), + ), + ); else yield* output.success("", report); }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index e9dd500ff5..ff7f06f02a 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -52,6 +52,16 @@ const rest: ServiceCreation = { config: {}, endpoints: { http: { port: 54321 } }, }; +const auth: ServiceCreation = { + service: "auth", + config: { jwtSecret }, + endpoints: { http: { port: 54321 } }, +}; +const studio: ServiceCreation = { + service: "studio", + config: {}, + endpoints: { http: { port: 54323 } }, +}; const functions: ServiceCreation = { service: "functions", config: { @@ -243,16 +253,11 @@ const runStatus = (input: { return { effect, out, root }; }).pipe(Effect.provide(BunServices.layer)); -it.live("reports observed lifecycle and health without requesting credentials", () => +it.live("renders connections and a services summary without internal IDs", () => Effect.gen(function* () { const databaseCalls = { value: 0 }; const restCalls = { value: 0 }; const authCalls = { value: 0 }; - const auth: ServiceCreation = { - service: "auth", - config: { jwtSecret }, - endpoints: { http: { port: 54325 } }, - }; const services = [ makeService({ id: "database-id", @@ -283,14 +288,27 @@ it.live("reports observed lifecycle and health without requesting credentials", }), }), ]; - const run = yield* runStatus({ services, reachable: true }); + const run = yield* runStatus({ + services, + members: [ + { id: "database-id", activation: "eager" }, + { id: "rest-id", activation: "lazy" }, + { id: "auth-id", activation: "lazy" }, + ], + reachable: true, + }); yield* run.effect; - expect(run.out.stdoutText).toContain("Owner: reachable"); - expect(run.out.stdoutText).toContain("database (database-id): running"); - expect(run.out.stdoutText).toContain("rest (rest-id): sleeping"); - expect(run.out.stdoutText).toContain("auth (auth-id): unhealthy"); - expect(run.out.stdoutText).toContain("health=unhealthy"); - expect(run.out.stdoutText).toContain("Readiness: unhealthy"); + const text = run.out.stdoutText; + expect(text).toMatch(/^Stack status-stack · unhealthy · native · /u); + expect(text).toMatch(/Project URL │ http:\/\/127\.0\.0\.1:54321 +│/u); + expect(text).toContain("postgresql://supabase_admin:postgres@127.0.0.1:54322/postgres"); + expect(text).toMatch(/Publishable │ saved-publishable-key +│/u); + expect(text).toMatch(/database +│ running · healthy · eager +│/u); + expect(text).toMatch(/rest +│ sleeping · starts on first request +│/u); + expect(text).toMatch(/auth +│ unhealthy · lazy +│/u); + expect(text).toContain("Project configuration matches the saved composition members."); + expect(text).not.toContain("database-id"); + expect(text).not.toContain(stackId); expect(databaseCalls.value).toBe(1); expect(restCalls.value).toBe(1); expect(authCalls.value).toBe(1); @@ -395,44 +413,97 @@ it.live("reports stopped readiness without treating unbound endpoints as drift", it.live("reports the planned differences of composition members as drift", () => Effect.gen(function* () { - const run = yield* runStatus({ - services: [ - makeService({ - id: "database-id", - creation: database, - statusCalls: { value: 0 }, - observation: makeObservation("database-id", database, { - lifecycle: "stopped", - wakeEnabled: false, + const drifted = (outputFormat: StatusOutputFormat) => + runStatus({ + services: [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "stopped", + wakeEnabled: false, + }), }), - }), - ], - members: [{ id: "database-id", activation: "eager" }], - planned: [ - { - id: "database-id", - service: "database", - member: true, - change: "incompatible", - paths: ["endpoints.sql.port"], - }, - { - id: "standalone-rest", - service: "rest", - member: false, - change: "changed", - paths: ["config.maxRows"], - }, - ], - config: "explicit", - reachable: false, - outputFormat: "json", - }); - yield* run.effect; - const result = run.out.messages.find((message) => message.type === "success")?.data; - expect(result).toMatchObject({ + ], + members: [{ id: "database-id", activation: "eager" }], + planned: [ + { + id: "database-id", + service: "database", + member: true, + change: "incompatible", + paths: ["endpoints.sql.port"], + }, + { + id: "standalone-rest", + service: "rest", + member: false, + change: "changed", + paths: ["config.maxRows"], + }, + ], + config: "explicit", + reachable: false, + outputFormat, + }); + const json = yield* drifted("json"); + yield* json.effect; + expect(json.out.messages.find((message) => message.type === "success")?.data).toMatchObject({ config_drift: { status: "changed", paths: ["services.database.endpoints.sql.port"] }, }); + const text = yield* drifted("text"); + yield* text.effect; + expect(text.out.stdoutText).toContain( + "1 configured service value differs from the saved stack.\n services.database.endpoints.sql.port\nRun supabase stack stop, then supabase stack start to apply the changes.\n", + ); + }), +); + +it.live("reports the Studio MCP and gateway API endpoints without REST", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + wakeEnabled: false, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "auth-id", + creation: auth, + statusCalls: { value: 0 }, + observation: makeObservation("auth-id", auth, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + makeService({ + id: "studio-id", + creation: studio, + statusCalls: { value: 0 }, + observation: makeObservation("studio-id", studio, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54323 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + expect(report.out.messages.find((message) => message.type === "success")?.data).toMatchObject({ + endpoints: { + "studio.http": { url: "http://127.0.0.1:54323" }, + "studio.mcp": { port: 54323, url: "http://127.0.0.1:54323/api/mcp" }, + }, + }); + const env = yield* runStatus({ services, reachable: true, flags: flags({ env: true }) }); + yield* env.effect; + expect(env.out.stdoutText).toContain("MCP_URL='http://127.0.0.1:54323/api/mcp'"); + expect(env.out.stdoutText).toContain("STUDIO_URL='http://127.0.0.1:54323'"); + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); }), ); @@ -476,9 +547,9 @@ it.live("reports unavailable owner and does not query service status", () => ]; const run = yield* runStatus({ services, reachable: false }); yield* run.effect; - expect(run.out.stdoutText).toContain("Owner: unavailable"); - expect(run.out.stdoutText).toContain("Lifecycle: unavailable"); - expect(run.out.stdoutText).toContain("database (database-id): unavailable"); + expect(run.out.stdoutText).toMatch(/^Stack status-stack · unavailable · /u); + expect(run.out.stdoutText).toContain("The stack owner is not running."); + expect(run.out.stdoutText).toMatch(/database +│ unavailable · lazy +│/u); expect(statusCalls.value).toBe(0); }), ); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 6054c9a3a7..41206e92e0 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -198,24 +198,87 @@ export const textOutputLayer = Layer.effect( return value; }); + // Writes pause the shown task spinner so they never share its row; the task's final + // line waits for the last in-flight write. + interface ShownSpinner { + handle: ReturnType; + message: string; + pauses: number; + settle?: () => void; + } + let activeSpinner: ShownSpinner | undefined; + + const pauseSpinner = (): ShownSpinner | undefined => { + if (activeSpinner === undefined) return undefined; + if (activeSpinner.pauses === 0) activeSpinner.handle.clear(); + activeSpinner.pauses += 1; + return activeSpinner; + }; + + // Without the guide, a resume adds no extra `│` line. + const resumeSpinner = (paused: ShownSpinner | undefined) => { + if (paused === undefined || paused.pauses === 0) return; + paused.pauses -= 1; + if (paused.pauses > 0) return; + paused.handle = spinner({ withGuide: false }); + paused.handle.start(formatTaskMessage(paused.message)); + paused.settle?.(); + }; + + const logAround = ( + emit: (message: string, opts?: { spacing?: number }) => void, + message: string, + ) => { + const paused = pauseSpinner(); + if (paused === undefined) emit(message); + else emit(message, { spacing: 0 }); + resumeSpinner(paused); + }; + + // A spinner due to appear during an unpaused write waits until such writes finish. + let unpausedWrites = 0; + let showWhenIdle: (() => void) | undefined; + + const withSpinnerPaused = (effect: Effect.Effect): Effect.Effect => + Effect.suspend(() => { + const paused = pauseSpinner(); + if (paused !== undefined) + return effect.pipe(Effect.ensuring(Effect.sync(() => resumeSpinner(paused)))); + unpausedWrites += 1; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + unpausedWrites -= 1; + if (unpausedWrites > 0 || showWhenIdle === undefined) return; + const show = showWhenIdle; + showWhenIdle = undefined; + show(); + }), + ), + ); + }); + return Output.of({ format: "text" as const, interactive: tty.stdoutIsTty, intro: (title: string) => Effect.sync(() => intro(title)), outro: (message: string) => Effect.sync(() => outro(message)), - info: (message: string) => Effect.sync(() => log.info(message)), - warn: (message: string) => Effect.sync(() => log.warn(message)), - error: (message: string) => Effect.sync(() => log.error(message)), + info: (message: string) => Effect.sync(() => logAround(log.info, message)), + warn: (message: string) => Effect.sync(() => logAround(log.warn, message)), + error: (message: string) => Effect.sync(() => logAround(log.error, message)), event: (event: StreamEvent) => - event.type === "log-entry" - ? Effect.sync(() => log.info(`[${event.service}] ${event.line}`)) - : Effect.sync(() => log.info(JSON.stringify(event))), + Effect.sync(() => + logAround( + log.info, + event.type === "log-entry" ? `[${event.service}] ${event.line}` : JSON.stringify(event), + ), + ), task: (message: string) => Effect.sync(() => { let shown = false; let settled = false; let currentMessage = message; - let task: ReturnType | undefined; + let shownSpinner: ShownSpinner | undefined; let timeout: ReturnType | undefined; const cancelPendingStart = () => { @@ -228,17 +291,28 @@ export const textOutputLayer = Layer.effect( const finish = (render: () => void) => { settled = true; cancelPendingStart(); - render(); + const settle = () => { + render(); + if (activeSpinner === shownSpinner) activeSpinner = undefined; + }; + if (shownSpinner !== undefined && shownSpinner.pauses > 0) shownSpinner.settle = settle; + else settle(); }; - timeout = setTimeout(() => { + const show = () => { if (settled) { return; } - task = spinner(); + shownSpinner = { handle: spinner(), message: currentMessage, pauses: 0 }; shown = true; - task.start(currentMessage); + shownSpinner.handle.start(currentMessage); + activeSpinner = shownSpinner; + }; + + timeout = setTimeout(() => { timeout = undefined; + if (unpausedWrites > 0) showWhenIdle = show; + else show(); }, TASK_SPINNER_DELAY_MS); return { @@ -248,15 +322,17 @@ export const textOutputLayer = Layer.effect( return; } currentMessage = nextMessage; - if (shown) { - task?.message(formatTaskMessage(nextMessage)); + if (shownSpinner !== undefined) { + shownSpinner.message = nextMessage; + if (shownSpinner.pauses === 0) + shownSpinner.handle.message(formatTaskMessage(nextMessage)); } }), succeed: (nextMessage?: string) => Effect.sync(() => finish(() => { if (shown) { - task?.stop(formatTaskMessage(nextMessage)); + shownSpinner?.handle.stop(formatTaskMessage(nextMessage)); return; } if (nextMessage !== undefined) { @@ -268,7 +344,7 @@ export const textOutputLayer = Layer.effect( Effect.sync(() => finish(() => { if (shown) { - task?.error(formatTaskMessage(nextMessage)); + shownSpinner?.handle.error(formatTaskMessage(nextMessage)); return; } if (nextMessage !== undefined) { @@ -280,7 +356,7 @@ export const textOutputLayer = Layer.effect( Effect.sync(() => finish(() => { if (shown) { - task?.clear(); + shownSpinner?.handle.clear(); } if (nextMessage !== undefined) { log.info(nextMessage); @@ -291,7 +367,7 @@ export const textOutputLayer = Layer.effect( Effect.sync(() => finish(() => { if (shown) { - task?.cancel(formatTaskMessage(nextMessage)); + shownSpinner?.handle.cancel(formatTaskMessage(nextMessage)); return; } if (nextMessage !== undefined) { @@ -303,7 +379,7 @@ export const textOutputLayer = Layer.effect( Effect.sync(() => finish(() => { if (shown) { - task?.clear(); + shownSpinner?.handle.clear(); } }), ), @@ -365,9 +441,15 @@ export const textOutputLayer = Layer.effect( }; }), result: () => Effect.void, - success: (message: string) => Effect.sync(() => log.success(message)), + success: (message: string) => Effect.sync(() => logAround(log.success, message)), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.sync(() => { + // A command failure is terminal, so a still-shown task spinner is dropped. + if (activeSpinner !== undefined) { + activeSpinner.handle.clear(); + activeSpinner.pauses = 0; + activeSpinner = undefined; + } // Bypasses clack's `log.error` framing (`│` guide + `■` icon): a // red-styled message on stderr, optionally followed by a suggestion. process.stderr.write(styleText("red", err.message) + "\n"); @@ -387,8 +469,10 @@ export const textOutputLayer = Layer.effect( ); } }), - raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), - rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + withSpinnerPaused(write(text, stream)), + rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => + withSpinnerPaused(write(bytes, stream)), }); }), ); diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index cb75d76811..69fedd246e 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { afterEach, beforeEach, vi } from "vitest"; -import { Cause, Effect, Exit, Layer, Sink, Stdio, Stream } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Sink, Stdio, Stream } from "effect"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; @@ -50,7 +50,7 @@ vi.mock("@clack/prompts", () => ({ outro: (a: unknown) => mockClack.outro(a), note: (a: unknown, b?: unknown, c?: unknown) => mockClack.note(a, b, c), log: mockClack.log, - spinner: () => mockClack.spinnerFactory(), + spinner: (opts?: unknown) => mockClack.spinnerFactory(opts), text: (a: unknown) => mockClack.text(a), password: (a: unknown) => mockClack.password(a), confirm: (a: unknown) => mockClack.confirm(a), @@ -338,6 +338,206 @@ describe("Output", () => { }).pipe(Effect.provide(sunk)); }); + it.effect("pauses and resumes the task spinner around a log while it is shown", () => + Effect.gen(function* () { + vi.useFakeTimers(); + const out = yield* Output; + yield* out.task("Loading organizations..."); + vi.advanceTimersByTime(200); + + yield* out.warn("no files matched pattern: missing.sql"); + + expect(mockClack.spinnerFactory).toHaveBeenNthCalledWith(2, { withGuide: false }); + expect(mockClack.log.warn).toHaveBeenCalledWith("no files matched pattern: missing.sql", { + spacing: 0, + }); + const [clear] = mockClack.spinnerHandle.clear.mock.invocationCallOrder; + const [warn] = mockClack.log.warn.mock.invocationCallOrder; + const [, resume] = mockClack.spinnerHandle.start.mock.invocationCallOrder; + expect(clear).toBeLessThan(warn!); + expect(warn).toBeLessThan(resume!); + }).pipe(Effect.provide(layer)), + ); + + it.effect( + "pauses and resumes the task spinner around a stderr raw write while it is shown", + () => { + const order: string[] = []; + const stderr: string[] = []; + const stdioLayer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => Sink.forEach((_item: string | Uint8Array) => Effect.void), + stderr: () => + Sink.forEach((item: string | Uint8Array) => + Effect.sync(() => { + order.push("write"); + stderr.push(typeof item === "string" ? item : new TextDecoder().decode(item)); + }), + ), + }), + ); + const sunk = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), + ); + return Effect.gen(function* () { + vi.useFakeTimers(); + const out = yield* Output; + yield* out.task("Loading organizations..."); + vi.advanceTimersByTime(200); + + mockClack.spinnerHandle.clear.mockImplementation(() => order.push("clear")); + mockClack.spinnerHandle.start.mockImplementation((msg?: string) => + order.push(`start:${msg}`), + ); + + yield* out.raw("raw line\n", "stderr"); + + expect(stderr).toEqual(["raw line\n"]); + expect(order).toEqual(["clear", "write", "start:Loading organizations..."]); + }).pipe(Effect.provide(sunk)); + }, + ); + + it.effect("resumes and settles the spinner only after overlapping raw writes finish", () => + Effect.gen(function* () { + const gate = (name: string) => + Effect.all({ entered: Deferred.make(), release: Deferred.make() }).pipe( + Effect.map((deferreds) => ({ name, ...deferreds })), + ); + const first = yield* gate("first\n"); + const second = yield* gate("second\n"); + const stdioLayer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => Sink.forEach((_item: string | Uint8Array) => Effect.void), + stderr: () => + Sink.forEach((item: string | Uint8Array) => { + const write = [first, second].find(({ name }) => name === item); + return write === undefined + ? Effect.void + : Deferred.succeed(write.entered, undefined).pipe( + Effect.andThen(Deferred.await(write.release)), + ); + }), + }), + ); + const sunk = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), + ); + yield* Effect.gen(function* () { + vi.useFakeTimers(); + const out = yield* Output; + const task = yield* out.task("Loading organizations..."); + vi.advanceTimersByTime(200); + vi.useRealTimers(); + + const firstWrite = yield* Effect.forkChild(out.raw(first.name, "stderr")); + yield* Deferred.await(first.entered); + const secondWrite = yield* Effect.forkChild(out.raw(second.name, "stderr")); + yield* Deferred.await(second.entered); + + yield* Deferred.succeed(first.release, undefined); + yield* Fiber.join(firstWrite); + yield* task.succeed("Loaded."); + expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(1); + expect(mockClack.spinnerHandle.stop).not.toHaveBeenCalled(); + + yield* Deferred.succeed(second.release, undefined); + yield* Fiber.join(secondWrite); + expect(mockClack.spinnerHandle.clear).toHaveBeenCalledTimes(1); + expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(2); + expect(mockClack.spinnerHandle.stop).toHaveBeenCalledWith("Loaded."); + }).pipe(Effect.provide(sunk)); + }), + ); + + it.effect("delays a due spinner until a raw write already in flight finishes", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const stdioLayer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => Sink.forEach((_item: string | Uint8Array) => Effect.void), + stderr: () => + Sink.forEach((_item: string | Uint8Array) => + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))), + ), + }), + ); + const sunk = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), + ); + yield* Effect.gen(function* () { + const out = yield* Output; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + yield* out.task("Loading organizations..."); + const write = yield* Effect.forkChild(out.raw("slow\n", "stderr")); + yield* Deferred.await(entered); + + vi.advanceTimersByTime(200); + expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(write); + expect(mockClack.spinnerHandle.start).toHaveBeenCalledWith("Loading organizations..."); + }).pipe(Effect.provide(sunk)); + }), + ); + + it.effect("settles the task through the spinner resumed after a log", () => + Effect.gen(function* () { + vi.useFakeTimers(); + const handle = () => ({ + start: vi.fn(), + stop: vi.fn(), + cancel: vi.fn(), + error: vi.fn(), + message: vi.fn(), + clear: vi.fn(), + isCancelled: false, + }); + const first = handle(); + const resumed = handle(); + mockClack.spinnerFactory.mockReturnValueOnce(first).mockReturnValueOnce(resumed); + const out = yield* Output; + const task = yield* out.task("Starting local Supabase stack..."); + vi.advanceTimersByTime(200); + + yield* out.info("Seeding globals from roles.sql..."); + yield* task.succeed("Stack is ready."); + + expect(first.clear).toHaveBeenCalledTimes(1); + expect(first.stop).not.toHaveBeenCalled(); + expect(resumed.start).toHaveBeenCalledWith("Starting local Supabase stack..."); + expect(resumed.stop).toHaveBeenCalledWith("Stack is ready."); + }).pipe(Effect.provide(layer)), + ); + + it.effect("clears a shown task spinner before rendering a command failure", () => + Effect.gen(function* () { + vi.useFakeTimers(); + const writes = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const out = yield* Output; + yield* out.task("Starting local Supabase stack..."); + vi.advanceTimersByTime(200); + + yield* out.fail({ code: "E_TEST", message: "no database", suggestion: "retry" }); + const [clear] = mockClack.spinnerHandle.clear.mock.invocationCallOrder; + const [firstWrite] = writes.mock.invocationCallOrder; + writes.mockRestore(); + + expect(clear).toBeLessThan(firstWrite!); + }).pipe(Effect.provide(layer)), + ); + it.effect("promptText interrupts on cancel", () => { mockClack.text.mockResolvedValue(Symbol.for("clack:cancel")); mockClack.isCancel.mockReturnValue(true); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index f8cb04d449..40e09273f6 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -62,6 +62,7 @@ import type { export { initialization, postgres } from "./Commands.ts"; export { resolveNativePostgresUser } from "./runtime/postgres-user.ts"; +export { apiRoute } from "./host/Endpoints.ts"; export { StackError } from "./Rpc.ts"; export type { ServiceCreation } from "./services/Catalog.ts"; /** A service creation as `services.create` accepts it, before stack credentials fill its inputs. */ diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index 03e491b57a..b07c7657fa 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -29,6 +29,7 @@ export const endpointPort = (creation: EndpointIntents, name: string): number | return endpoint.port === "auto" || typeof endpoint.port === "number" ? endpoint.port : "auto"; }; +/** Path prefix of a service on the shared API listener, or `undefined` when it has a dedicated one. */ export const apiRoute = (service: ServiceCreation["service"]): string | undefined => { switch (service) { case "rest": From 80dc4e02ec1bd115dd21fc6abc845d609e860020 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 30 Sep 2026 09:03:28 +0000 Subject: [PATCH 44/71] ci(repo): move the AI review pipeline to gpt-6.1-sol (#6899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Points the AI review pipeline's Codex side at `gpt-6.1-sol`, which supersedes `gpt-6-sol`. The model first shows up in the Codex CLI's bundled catalog (`codex-rs/models-manager/models.json`) in **0.159.1**. It isn't in 0.156.1 or 0.159.0. This PR pins **0.159.2**, the current patch on that line. The only thing it adds over 0.159.1 is a Windows console-window fix. - **Reasoning effort stays explicitly `high` on both Codex steps** (independent review and adjudication). This matters more now: the catalog's `default_reasoning_level` for `gpt-6.1-sol` is `low`, so without the input the review would run at low effort. The model supports `low` through `ultra`. - **The action pin does not move.** `openai/codex-action` stays on the v1.11 commit, and `codex-version` is still an independent input. openai/codex-action#151 and openai/codex-action#160 are still the reasons for the pin. - **The `codex exec` flag surface is unchanged.** `codex-rs/exec/src/cli.rs` has the same blob SHA at 0.156.1, 0.159.1 and 0.159.2, so every flag the pinned action passes still parses. Nothing under `codex-rs/exec/src/` mentions the daemon, so this can't reintroduce the lingering-descendant hang. This is a three-minor jump on the CLI, against a model release that is about a day old. A `workflow_dispatch` run against a real PR is worth doing before we trust it on the automatic path. ## Linked issue No linked issue — maintainer change. ## Checklist - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). - [ ] Tests added or updated for the change. — n/a, workflow configuration only. - [x] From the repository root, `pnpm check:all` passes; relevant package tests pass for every touched workspace, and `pnpm types:check` passes for each touched TypeScript workspace (or workspace declaring it). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/ai-review.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 5d7792d51a..8b96edfda8 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -28,7 +28,7 @@ permissions: {} # post-review's footer all read these instead of hardcoding them separately. env: CLAUDE_MODEL: claude-opus-5-5 - CODEX_MODEL: gpt-6-sol + CODEX_MODEL: gpt-6.1-sol # Non-command issue_comment events fire for every comment on every PR, so grouping by PR number # alone would let any comment cancel an in-flight review; give those runs their own per-run @@ -308,8 +308,8 @@ jobs: effort: high output-schema-file: /tmp/ai-review/findings.schema.json output-file: /tmp/ai-review/codex-findings.json - # 0.156.1 is the first stable CLI whose model catalog carries gpt-6-sol. - codex-version: &codex-cli-version "0.156.1" + # 0.159.1 is the first stable CLI whose model catalog carries gpt-6.1-sol. + codex-version: &codex-cli-version "0.159.2" working-directory: ${{ github.workspace }} safety-strategy: drop-sudo sandbox: read-only From 5800d06e24b3eb30d44cf367498d4e3ba259d0d4 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:17:57 +0000 Subject: [PATCH 45/71] test(repo): fix windows and live e2e flakes (#6895) ## TL;DR fixes four flaky tests across the windows stack, stack integration and live suites - `State.windows` gave the powershell holder 10s to print ready while passing runs already reach 10.1s. it now waits up to 30s as a guard, fails fast if the holder exits early and shows everything it printed - `State.process` compiled hit `EBUSY` removing its temp root because the killed child could still lock `fixture.exe` inside it. cleanup now waits for the child to exit, same as [#6878](https://github.com/supabase/cli/pull/6878) - `pull.live` failed binding the default shadow port 54320, which sits inside the linux ephemeral range where an outbound socket can hold it. live tests now pin the shadow to 24320 below that range, the same rule `packages/stack` ports follow - `ProcessRecipe` native tests downloaded the real postgrest artifact and timed out when github releases returned `HTTP 500`. they now seed the same fixture artifact the rest of the file uses ## ref: - spotted in: [windows holder](https://github.com/supabase/cli/actions/runs/36458455768/job/109050708036) [compiled cleanup](https://github.com/supabase/cli/actions/runs/36539355369/job/109310791407) [pull live](https://github.com/supabase/cli/actions/runs/36542815770/job/109322031138) [process recipe](https://github.com/supabase/cli/actions/runs/36596310724/job/109502008403) & more --- apps/cli/tests/helpers/live-env.ts | 6 +++ apps/cli/tests/helpers/live.ts | 4 +- .../src/State.process.integration.test.ts | 5 ++- .../src/State.windows.integration.test.ts | 42 +++++++++++++++---- .../ProcessRecipe.integration.test.ts | 38 ++++++++--------- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/apps/cli/tests/helpers/live-env.ts b/apps/cli/tests/helpers/live-env.ts index 92c2519b79..284e40906b 100644 --- a/apps/cli/tests/helpers/live-env.ts +++ b/apps/cli/tests/helpers/live-env.ts @@ -2,6 +2,12 @@ export const LIVE_EXIT_TIMEOUT_MS = 240_000; +/** + * The default shadow port (54320) is inside Linux's default ephemeral range, so an outbound socket + * can hold it. Live files run serially, so one fixed port below that range is safe. + */ +export const LIVE_SHADOW_PORT = "24320"; + export function liveApiUrl(): string { const value = process.env["SUPABASE_LIVE_API_URL"]?.trim(); if (value === undefined || value.length === 0) { diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index daada201b1..5421e6e75c 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -14,7 +14,7 @@ import { runSupabase, runSupabaseEffect, } from "./cli.ts"; -import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; +import { LIVE_EXIT_TIMEOUT_MS, LIVE_SHADOW_PORT } from "./live-env.ts"; import type { LiveCliProjectEnvironment } from "./live-project.ts"; export type LiveProject = LiveCliProjectEnvironment["project"]; @@ -88,6 +88,7 @@ const base = vitestTest.extend({ exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, env: { SUPABASE_PROFILE: inject("liveProfilePath"), + SUPABASE_DB_SHADOW_PORT: LIVE_SHADOW_PORT, ...options?.env, }, }), @@ -103,6 +104,7 @@ const base = vitestTest.extend({ exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, env: { SUPABASE_PROFILE: inject("liveProfilePath"), + SUPABASE_DB_SHADOW_PORT: LIVE_SHADOW_PORT, ...options?.env, }, }), diff --git a/packages/stack/src/State.process.integration.test.ts b/packages/stack/src/State.process.integration.test.ts index 7030ce3ef6..90dec13572 100644 --- a/packages/stack/src/State.process.integration.test.ts +++ b/packages/stack/src/State.process.integration.test.ts @@ -14,6 +14,7 @@ import { } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { fileURLToPath } from "node:url"; +import { ownerExitProbe, waitForOwnerExit } from "./HostProcess.ts"; import * as State from "./State.ts"; class FixtureError extends Data.TaggedError("StateLockFixtureError")<{ @@ -179,7 +180,9 @@ for (const compiled of [false, true]) { Effect.ignore, Effect.andThen(Fiber.join(output)), Effect.andThen(Fiber.join(diagnostics)), - Effect.timeout("5 seconds"), + // Windows keeps the compiled child's image in the temp root locked until it exits. + Effect.andThen(waitForOwnerExit(childPid, ownerExitProbe(fs)).pipe(Effect.ignore)), + Effect.timeout("10 seconds"), Effect.orDie, ), ); diff --git a/packages/stack/src/State.windows.integration.test.ts b/packages/stack/src/State.windows.integration.test.ts index c9080b2de9..cc43a6ca78 100644 --- a/packages/stack/src/State.windows.integration.test.ts +++ b/packages/stack/src/State.windows.integration.test.ts @@ -96,11 +96,18 @@ const testSharingViolation = () => ), ); const ready = yield* Deferred.make(); + const observed = yield* Ref.make>([]); const output = yield* holder.stdout.pipe( Stream.decodeText, Stream.splitLines, Stream.runForEach((line) => - line === "ready" ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) : Effect.void, + Ref.update(observed, (lines) => [...lines, line]).pipe( + Effect.andThen( + line === "ready" + ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), ), Effect.forkScoped, ); @@ -110,15 +117,32 @@ const testSharingViolation = () => Stream.runForEach((chunk) => Ref.update(stderr, (text) => text + chunk)), Effect.forkScoped, ); + const holderFailure = (reason: string) => + Effect.all([Ref.get(observed), Ref.get(stderr)]).pipe( + Effect.flatMap(([lines, text]) => + Effect.fail(new HolderError({ message: `${reason}: ${lines.join("\n")}\n${text}` })), + ), + ); + const exitFailure = (reason: string) => + Effect.all([Fiber.join(output), Fiber.join(diagnostics)]).pipe( + // The exit event can fire before the holder's stdio is drained. + Effect.timeout("5 seconds"), + Effect.ignore, + Effect.andThen(holderFailure(reason)), + ); yield* Deferred.await(ready).pipe( + Effect.raceFirst( + holder.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => exitFailure(`Holder exited before readiness: ${String(cause)}`), + onSuccess: (code) => exitFailure(`Holder exited before readiness (${code})`), + }), + ), + ), Effect.timeoutOrElse({ - duration: "10 seconds", - orElse: () => - Ref.get(stderr).pipe( - Effect.flatMap((text) => - Effect.fail(new HolderError({ message: `Holder did not become ready: ${text}` })), - ), - ), + // A guard only, not a readiness assertion. + duration: "30 seconds", + orElse: () => holderFailure("Holder did not become ready"), }), ); yield* Ref.set(armed, true); @@ -154,5 +178,5 @@ const testSharingViolation = () => it.live.skipIf(process.platform !== "win32")( "retries a real Windows sharing violation after the open state handle is released", () => testSharingViolation(), - 30_000, + 60_000, ); diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index c4f22115f5..6e7484b6d7 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -24,7 +24,6 @@ import { systemError } from "effect/PlatformError"; import * as Net from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- the collision fixture owns a local HTTP listener. import * as NodeHttp from "node:http"; -import { prepareNativeArtifact } from "../Artifacts.ts"; import { makeArtifactStore, type ArtifactRequest, @@ -246,10 +245,13 @@ describe("ProcessRecipe launch cleanup", () => { }), ), ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-native-" }); + const cacheRoot = path.join(root, "cache"); + yield* nativeRestArtifact(cacheRoot); const nativeOptions: CatalogOptions = { ...options, - root: yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-native-" }), - cacheRoot: "/tmp/supabase-stack-artifacts", + root, + cacheRoot, runtime: "native", }; const nativeSpec: ProcessRecipeSpec = { @@ -294,17 +296,8 @@ describe("ProcessRecipe launch cleanup", () => { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const testScope = yield* Scope.Scope; const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-port-order-" }); - const cacheRoot = "/tmp/supabase-stack-artifacts"; - const artifact = yield* prepareNativeArtifact( - { service: "rest", version: "v16.2" }, - cacheRoot, - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HttpClient.HttpClient, client), - ); + const cacheRoot = path.join(root, "cache"); + const artifact = yield* nativeRestArtifact(cacheRoot); const nativeOptions: CatalogOptions = { ...options, root, @@ -367,7 +360,7 @@ describe("ProcessRecipe launch cleanup", () => { }), startupCommands: [ { - nativeExecutable: path.relative(path.join(artifact.root, "bin"), process.execPath), + nativeExecutable: path.relative(path.join(artifact.path, "bin"), process.execPath), args: [ "-e", `import net from "node:net"; const server = net.createServer(); server.once("error", () => process.exit(17)); server.listen(Number(process.env.PORT), "127.0.0.1", () => server.close((error) => process.exit(error ? 18 : 0)));`, @@ -508,9 +501,16 @@ const nativeFixtureArtifact = Effect.fn(function* ( ), }; const store = yield* makeArtifactStore({ cacheRoot, source }); - yield* store.prepare(request); + return yield* store.prepare(request); }); +const nativeRestArtifact = (cacheRoot: string, program = "") => + nativeFixtureArtifact(cacheRoot, { + name: "postgrest/v16.2", + executablePath: "bin/postgrest", + files: { "bin/postgrest": `#!${process.execPath}\n${program}` }, + }); + const nativePoolerArtifact = (cacheRoot: string) => { const server = `#!${process.execPath}\nconst http = require("node:http");\n` + @@ -631,11 +631,7 @@ const nativeRestRecipe = Effect.fn(function* ( const crypto = yield* Crypto.Crypto; const client = yield* HttpClient.HttpClient; const cacheRoot = path.join(root, "cache"); - yield* nativeFixtureArtifact(cacheRoot, { - name: "postgrest/v16.2", - executablePath: "bin/postgrest", - files: { "bin/postgrest": `#!${process.execPath}\n${program}` }, - }); + yield* nativeRestArtifact(cacheRoot, program); return yield* makeProcessRecipe( creation, { From a1be13e80573a0eac2ce13713dc2501a1f27c2fd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 09:46:31 +0000 Subject: [PATCH 46/71] fix(stack): open a fresh upstream connection per proxied request (#6897) ## Summary The stack's HTTP proxy sent upstream requests through the runtime's shared keep-alive agent. A backend that was just woken can reset a pooled connection between requests; with Studio on Docker the request after the first one reliably failed with `502 Bad Gateway` (`socket hang up` in the owner log). Only safe, bodyless requests are retried, so POSTs such as MCP calls surfaced the failure to the client. Upstream requests now use `agent: false`, opening a connection per proxied request. `Connection: keep-alive` stays on the wire because Bun ends a still-streaming upstream response early when the request says `Connection: close`. --------- Co-authored-by: Julien Goux --- .../stack/src/HttpProxy.integration.test.ts | 62 ++++++++++++++++++- packages/stack/src/HttpProxy.ts | 6 +- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index 167df27dac..16c6f3eb8e 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -3,13 +3,13 @@ import { expect, it } from "@effect/vitest"; import { Data, Deferred, Effect, Fiber, Layer, Logger, type LogLevel } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { createServer, type Server, type ServerResponse } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw server fixture. -import { Socket } from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw disconnect fixture. +import { createServer as createTcpServer, Socket, type Server as NetServer } from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw disconnect fixture. // oxlint-disable-next-line effecttsgo/node-builtin-import -- raw WebSocket upgrade fixture. import { WebSocket, WebSocketServer } from "ws"; import { ProxyError } from "./Proxy.ts"; import { makeHttpProxy, type HttpRoute } from "./HttpProxy.ts"; -const listen = (server: Server) => +const listen = (server: Server | NetServer, options?: { readonly beforeClose?: () => void }) => Effect.acquireRelease( Effect.callback<{ host: string; port: number }, HttpProxyTestError>((resume) => { const onError = (cause: Error) => @@ -25,6 +25,7 @@ const listen = (server: Server) => }), () => Effect.callback((resume) => { + options?.beforeClose?.(); server.close(() => resume(Effect.void)); return Effect.void; }), @@ -554,6 +555,63 @@ it.live("does not replay a request with a body when the upstream drops the conne ); }); +it.live( + "succeeds a second POST when a keep-alive backend only answers the first request per connection", + () => + Effect.scoped( + Effect.gen(function* () { + let connections = 0; + const sockets = new Set(); + // Keeps each connection open after answering, then resets it if a second request reuses it. + const backend = createTcpServer((socket) => { + connections += 1; + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + let buffered = Buffer.alloc(0); + let bodyEnd: number | undefined; + let answered = false; + socket.on("data", (chunk: Buffer) => { + if (answered) { + socket.resetAndDestroy(); + return; + } + buffered = Buffer.concat([buffered, chunk]); + if (bodyEnd === undefined) { + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const contentLength = Number( + /content-length:\s*(\d+)/iu.exec( + buffered.subarray(0, headerEnd).toString("latin1"), + )?.[1] ?? 0, + ); + bodyEnd = headerEnd + 4 + contentLength; + } + if (buffered.length < bodyEnd) return; + answered = true; + socket.write( + "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok", + ); + }); + }); + const address = yield* listen(backend, { + beforeClose: () => { + for (const socket of sockets) socket.destroy(); + }, + }); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "mcp", prefix: "/", target: Effect.succeed(address) }]); + const first = yield* request(proxy.port, "/mcp", new TextEncoder().encode("first-body")); + expect(first.status).toBe(200); + expect(new TextDecoder().decode(first.body)).toBe("ok"); + const second = yield* request(proxy.port, "/mcp", new TextEncoder().encode("second-body")); + expect(second.status).toBe(200); + expect(new TextDecoder().decode(second.body)).toBe("ok"); + expect(connections).toBe(2); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live( "does not replay a bodyless non-idempotent request when the upstream drops the connection", () => { diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index 10755ad82f..881c9c28b0 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -284,9 +284,13 @@ const forward = Effect.fn("HttpProxy.forward")( host: "path" in backend ? undefined : backend.host, port: "path" in backend ? undefined : backend.port, socketPath: "path" in backend ? backend.path : undefined, + // A reused upstream connection can be reset by a just-woken backend, and a body + // cannot be replayed. + agent: false, method: request.method, path: pathFor(request, route), - headers: upstreamHeadersFor(request.headers, route), + // Bun ends a streamed upstream response early when the request says Connection: close. + headers: { ...upstreamHeadersFor(request.headers, route), connection: "keep-alive" }, }, (value) => { incoming = value; From b56c76595e828a8d84cdc82d6bbcd91dccd793ba Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:31:10 +0000 Subject: [PATCH 47/71] refactor(cli): cover `shared/git` and `shared/issue` with effect lint (CLI-2558) (#6902) ## TL;DR brings the `git` and `issue` areas under the effect lint ## whats introduced? effect lint applied to the git and issue helpers and their tests: - `shared/git/**` and `shared/issue/**` allow list entries - `detectGitBranch` reads `GITHUB_HEAD_REF` through `Config` instead of `process.env`, and an empty value still falls back to `.git/HEAD` - `inferIssueInstallMethod` reads `SUPABASE_INSTALL_METHOD` and `npm_config_user_agent` through `Config`, with the same trim and the same fallbacks - `findGitRootPath` walks up through the effect `FileSystem` and `Path` services instead of `node:fs` and `node:path`, and still treats a failed stat as no `.git` there - `buildDockerBinds` resolves the source root as an effect first and then runs the same bind logic, so a rejection stays a defect for `functions deploy` and `functions serve` - tests use scoped temp dirs and a config provider instead of `node:fs` and a hand set `process.env` - `withConfigEnv` pins env for `Config` reads in tests, and the `branches create` and `db reset` tests use it for `GITHUB_HEAD_REF` ## ref: - closes: CLI-2558 --- .oxlintrc.effect.json | 2 + .../create/create.integration.test.ts | 8 +- .../db/reset/reset.integration.test.ts | 6 +- .../gen/signing-key/signing-key.handler.ts | 5 +- apps/cli/src/commands/issue/issue.handler.ts | 2 +- apps/cli/src/shared/functions/deploy.ts | 56 +++--- .../src/shared/functions/deploy.unit.test.ts | 43 ++-- apps/cli/src/shared/functions/serve.ts | 40 ++-- apps/cli/src/shared/git/git-branch.ts | 14 +- .../src/shared/git/git-branch.unit.test.ts | 190 ++++++++---------- apps/cli/src/shared/git/git-root.ts | 25 ++- .../issue-template-contract.unit.test.ts | 156 +++++++------- apps/cli/src/shared/issue/issue-url.ts | 21 +- apps/cli/tests/helpers/command-mocks.ts | 34 +++- 14 files changed, 322 insertions(+), 280 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index d96a832831..a222728c34 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -15,6 +15,8 @@ "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", + "!apps/cli/src/shared/git/**", + "!apps/cli/src/shared/issue/**", "!apps/cli/src/shared/runtime/**", "!apps/cli/src/shared/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index e28ddd0ccb..e8e2b4dfe2 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -20,6 +20,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { branchesCreateCommand, type BranchesCreateFlags } from "./create.command.ts"; @@ -225,9 +226,8 @@ describe("branches create integration", () => { it.live("reports a missing name before contacting the API outside a git repository", () => { const { layer, api } = setup(); - return withEnvVar( - "GITHUB_HEAD_REF", - undefined, + return withConfigEnv( + { GITHUB_HEAD_REF: "" }, Effect.gen(function* () { const exit = yield* branchesCreate(baseFlags).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -246,7 +246,7 @@ describe("branches create integration", () => { // `GITHUB_HEAD_REF` drives `detectGitBranch` deterministically (its highest-priority source). const withGitBranch = (effect: Effect.Effect, branch = "feat-y") => - withEnvVar("GITHUB_HEAD_REF", branch, effect); + withConfigEnv({ GITHUB_HEAD_REF: branch }, effect); it.live("--yes auto-confirms the git-branch name with the [Y/n] y echo", () => { const { layer, out, api } = setup({ yes: true, stdinIsTty: true }); diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 8f889211d1..2252d027c2 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -28,6 +28,7 @@ import { import { VALID_REF, jsonResponse, + withConfigEnv, withEnvVar, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -1917,9 +1918,8 @@ describe("db reset", () => { args: ["db", "reset", "--local"], isLocal: true, }); - return withEnvVar( - "GITHUB_HEAD_REF", - "feature-x", + return withConfigEnv( + { GITHUB_HEAD_REF: "feature-x" }, Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("on branch "); diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts index 2a6b1278d5..840df06425 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts @@ -177,10 +177,11 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: string) { const path = yield* Path.Path; - const gitRoot = yield* Effect.tryPromise(() => findGitRootPath(searchFrom)).pipe(Effect.orDie); - if (gitRoot === undefined) { + const gitRootOption = yield* findGitRootPath(searchFrom); + if (Option.isNone(gitRootOption)) { return Option.none(); } + const gitRoot = gitRootOption.value; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relative = path.relative(gitRoot, filePath).replaceAll("\\", "/"); diff --git a/apps/cli/src/commands/issue/issue.handler.ts b/apps/cli/src/commands/issue/issue.handler.ts index 9828a818bf..46d9b6dae2 100644 --- a/apps/cli/src/commands/issue/issue.handler.ts +++ b/apps/cli/src/commands/issue/issue.handler.ts @@ -34,7 +34,7 @@ export const issueBug = Effect.fn("issue.bug")(function* (flags: IssueBugFlags) "affected-area": readIssueFlagValue(flags.area), "cli-version": telemetryRuntime.cliVersion, os: `${runtimeInfo.platform} ${runtimeInfo.arch}`, - "install-method": inferIssueInstallMethod(runtimeInfo), + "install-method": yield* inferIssueInstallMethod(runtimeInfo), command: readIssueFlagValue(flags.command), "actual-output": readIssueFlagValue(flags.actualOutput), "expected-behavior": readIssueFlagValue(flags.expectedBehavior), diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 323929f198..53104dbaaf 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -405,9 +405,8 @@ async function realpathIfExists(pathname: string) { } } -async function resolveFunctionsSourceRoot(projectRoot: string) { - return (await findGitRootPath(projectRoot)) ?? resolve(projectRoot); -} +const resolveFunctionsSourceRoot = (projectRoot: string) => + findGitRootPath(projectRoot).pipe(Effect.map(Option.getOrElse(() => resolve(projectRoot)))); function humanSize(bytes: number) { if (bytes < 1000) { @@ -1207,23 +1206,39 @@ function sanitizeDockerBinds( return result; } -export async function buildDockerBinds( +type DockerBindsOptions = { + readonly additionalModuleRoots?: ReadonlyArray; + readonly onWarning?: (message: string) => Promise; + readonly skipMissingImportMapTargets?: boolean; + /** Resolved marker presence, including an explicitly empty project value. */ + readonly bitbucketCloneDirDefined?: boolean; +}; + +export const buildDockerBinds = ( + projectId: string, + functionsDir: string, + outputDir: string, + config: ResolvedDeployFunctionConfig, + options: DockerBindsOptions = {}, +) => + resolveFunctionsSourceRoot(resolve(functionsDir, "..", "..")).pipe( + Effect.flatMap((sourceRoot) => + Effect.promise(() => + buildDockerBindsWithin(sourceRoot, projectId, functionsDir, outputDir, config, options), + ), + ), + ); + +async function buildDockerBindsWithin( + sourceRoot: string, projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, - options: { - readonly additionalModuleRoots?: ReadonlyArray; - readonly onWarning?: (message: string) => Promise; - readonly skipMissingImportMapTargets?: boolean; - /** Resolved marker presence, including an explicitly empty project value. */ - readonly bitbucketCloneDirDefined?: boolean; - } = {}, + options: DockerBindsOptions, ): Promise> { const hostFunctionsDir = resolve(functionsDir); const hostOutputDir = resolve(outputDir); - const projectRoot = resolve(functionsDir, "..", ".."); - const sourceRoot = await resolveFunctionsSourceRoot(projectRoot); const realSourceRoot = await realpath(sourceRoot); const moduleRoots = [ realSourceRoot, @@ -1443,12 +1458,10 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( // `edgeRuntimeImage` applies the tag verbatim — a `.temp/edge-runtime-version` pin flows // through unmodified, `v` prefix or not (see the helper's doc in `functions.shared.ts`). const rawImage = edgeRuntimeImage(edgeRuntimeVersion); - const binds = yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, outputDir, config, { - bitbucketCloneDirDefined, - onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), - }), - ); + const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { + bitbucketCloneDirDefined, + onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), + }); // Resolved per function rather than hoisted out of the loop (unlike `download.ts`'s // `PulledEdgeRuntimeImage`): the first resolve failure aborts the loop, and the only added // cost is one cached `docker image inspect` per function. @@ -2083,10 +2096,7 @@ const deployViaApi = Effect.fnUntraced(function* ( // (`projectRoot`), not at `sourceRoot`. The import-walk boundary (which files may be uploaded // at all) is intentionally wider, extending to the nearest git root, so files outside the // workdir but inside a monorepo can still deploy — those upload with `../`-relative names. - const sourceRoot = yield* Effect.tryPromise({ - try: () => resolveFunctionsSourceRoot(projectRoot), - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const sourceRoot = yield* resolveFunctionsSourceRoot(projectRoot); const enabled = configs.filter((config) => config.enabled); for (const skipped of configs.filter((config) => !config.enabled)) { yield* output.raw(`Skipping disabled Function: ${skipped.slug}\n`, "stderr"); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index ec46dc9965..eff5003774 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -2,6 +2,8 @@ import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from "node:f import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -12,6 +14,9 @@ import { } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; +const runBuildDockerBinds = (...args: Parameters) => + Effect.runPromise(buildDockerBinds(...args).pipe(Effect.provide(BunServices.layer))); + /** * `../../` from `/supabase/functions/hello/deno.json`'s directory lands at * `/supabase/_vendor/package/dist/index.mjs`, outside `functionsDir` @@ -114,7 +119,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -140,7 +145,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { let caught: unknown; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async () => {}, }); } catch (error) { @@ -170,7 +175,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -195,7 +200,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -217,7 +222,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -249,7 +254,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -274,7 +279,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeVendorIndexFile(root); try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); + await runBuildDockerBinds("test-project", functionsDir, outputDir, config); } finally { await rm(root, { recursive: true, force: true }); } @@ -292,7 +297,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -318,14 +323,14 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { let threwWithoutOption = false; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); + await runBuildDockerBinds("test-project", functionsDir, outputDir, config); } catch { threwWithoutOption = true; } expect(threwWithoutOption).toBe(true); const warnings: Array = []; - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -353,7 +358,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -386,7 +391,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeDependency, 'export const dependency = "scope";\n'); try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config); const hostPaths = binds.map((bind) => bind.hostPath); expect(hostPaths).toContain(scopeEntrypoint); @@ -414,7 +419,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeEntrypoint, 'export { util } from "./util.ts";\n'); await writeFile(scopeDependency, 'export const util = "thing";\n'); - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -455,7 +460,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -489,7 +494,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -515,7 +520,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -545,7 +550,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -576,7 +581,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -611,7 +616,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 24af7444a4..78d0ecf405 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1676,17 +1676,15 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { - bitbucketCloneDirDefined, - additionalModuleRoots: [flagCwd], - skipMissingImportMapTargets: true, - onWarning: (message) => { - bindWarnings.push(message); - return Promise.resolve(); - }, - }), - )) { + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { + bitbucketCloneDirDefined, + additionalModuleRoots: [flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Promise.resolve(); + }, + })) { binds.add(formatDockerBind(bind)); } const missingSourceWarning = bindWarnings.find((warning) => @@ -1771,17 +1769,15 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, config, { - bitbucketCloneDirDefined, - additionalModuleRoots: [input.flagCwd], - skipMissingImportMapTargets: true, - onWarning: (message) => { - bindWarnings.push(message); - return Promise.resolve(); - }, - }), - )) { + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, config, { + bitbucketCloneDirDefined, + additionalModuleRoots: [input.flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Promise.resolve(); + }, + })) { const key = formatDockerBind(bind); functionBinds.set(key, bind); if (!bind.externalScope) { diff --git a/apps/cli/src/shared/git/git-branch.ts b/apps/cli/src/shared/git/git-branch.ts index 114f210797..338c23f529 100644 --- a/apps/cli/src/shared/git/git-branch.ts +++ b/apps/cli/src/shared/git/git-branch.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Config, Effect, FileSystem, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; @@ -14,11 +14,15 @@ import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; */ export const detectGitBranch = ( startDir?: string, -): Effect.Effect, never, RuntimeInfo | FileSystem.FileSystem | Path.Path> => +): Effect.Effect< + Option.Option, + Config.ConfigError, + RuntimeInfo | FileSystem.FileSystem | Path.Path +> => Effect.gen(function* () { - const githubHeadRef = process.env["GITHUB_HEAD_REF"]; - if (githubHeadRef !== undefined && githubHeadRef.length > 0) { - return Option.some(githubHeadRef); + const githubHeadRef = yield* Config.option(Config.string("GITHUB_HEAD_REF")); + if (Option.isSome(githubHeadRef) && githubHeadRef.value.length > 0) { + return githubHeadRef; } const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/shared/git/git-branch.unit.test.ts b/apps/cli/src/shared/git/git-branch.unit.test.ts index 1c8640f8ca..c111eff1ff 100644 --- a/apps/cli/src/shared/git/git-branch.unit.test.ts +++ b/apps/cli/src/shared/git/git-branch.unit.test.ts @@ -1,136 +1,106 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { detectGitBranch } from "./git-branch.ts"; -function withCwd(cwd: string) { +function withCwd(cwd: string, env: Record = {}) { return Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), Layer.succeed(RuntimeInfo, { cwd, platform: process.platform, arch: process.arch, - homeDir: tmpdir(), + homeDir: cwd, execPath: process.execPath, pid: process.pid, }), ); } +const makeTempDir = (prefix: string) => + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.makeTempDirectoryScoped({ prefix })); + +const writeHead = Effect.fnUntraced(function* (root: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(root, ".git")); + yield* fs.writeFileString(path.join(root, ".git", "HEAD"), contents); +}); + describe("detectGitBranch", () => { - let original: string | undefined; + it.live("returns $GITHUB_HEAD_REF when set", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-ci-"); + const got = yield* detectGitBranch().pipe( + Effect.provide(withCwd(root, { GITHUB_HEAD_REF: "ci-branch" })), + ); + expect(got).toEqual(Option.some("ci-branch")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns $GITHUB_HEAD_REF when set", () => { - original = process.env["GITHUB_HEAD_REF"]; - process.env["GITHUB_HEAD_REF"] = "ci-branch"; - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("ci-branch"); - } finally { - if (original === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = original; - } - }).pipe(Effect.provide(withCwd(tmpdir()))); - }); + it.live("ignores an empty $GITHUB_HEAD_REF and falls back to .git/HEAD", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-empty-ref-"); + yield* writeHead(root, "ref: refs/heads/feature-x\n"); + const got = yield* detectGitBranch().pipe( + Effect.provide(withCwd(root, { GITHUB_HEAD_REF: "" })), + ); + expect(got).toEqual(Option.some("feature-x")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("parses ref: refs/heads/ from .git/HEAD in CWD", () => { - const original2 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-")); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/feature-x\n"); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("feature-x"); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original2 !== undefined) process.env["GITHUB_HEAD_REF"] = original2; - } - }).pipe(Effect.provide(withCwd(root))); - }); + it.live("parses ref: refs/heads/ from .git/HEAD in CWD", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-"); + yield* writeHead(root, "ref: refs/heads/feature-x\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(got).toEqual(Option.some("feature-x")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("walks up parent directories until .git/HEAD is found", () => { - const original3 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-walk-")); - const nested = join(root, "a", "b", "c"); - mkdirSync(nested, { recursive: true }); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/main\n"); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("main"); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original3 !== undefined) process.env["GITHUB_HEAD_REF"] = original3; - } - }).pipe(Effect.provide(withCwd(nested))); - }); + it.live("walks up parent directories until .git/HEAD is found", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("git-branch-walk-"); + const nested = path.join(root, "a", "b", "c"); + yield* fs.makeDirectory(nested, { recursive: true }); + yield* writeHead(root, "ref: refs/heads/main\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(nested))); + expect(got).toEqual(Option.some("main")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns none when no .git/HEAD is ever found", () => { - const original4 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-empty-")); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isNone(got)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original4 !== undefined) process.env["GITHUB_HEAD_REF"] = original4; - } - }).pipe(Effect.provide(withCwd(root))); - }); + it.live("returns none when no .git/HEAD is ever found", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-none-"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns none when .git/HEAD points at a detached commit (no ref: line)", () => { - const original5 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-detached-")); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "deadbeef\n"); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isNone(got)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original5 !== undefined) process.env["GITHUB_HEAD_REF"] = original5; - } - }).pipe(Effect.provide(withCwd(root))); - }); + it.live("returns none when .git/HEAD points at a detached commit (no ref: line)", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-detached-"); + yield* writeHead(root, "deadbeef\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("walks from an explicit startDir instead of the runtime CWD", () => { - const original6 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an - // unrelated dir with no repo, mirroring `supabase --workdir ` run - // from elsewhere. - const project = mkdtempSync(join(tmpdir(), "git-branch-workdir-")); - mkdirSync(join(project, ".git")); - writeFileSync(join(project, ".git", "HEAD"), "ref: refs/heads/project-branch\n"); - const elsewhere = mkdtempSync(join(tmpdir(), "git-branch-cwd-")); - return Effect.gen(function* () { - const got = yield* detectGitBranch(project); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("project-branch"); - } finally { - rmSync(project, { recursive: true, force: true }); - rmSync(elsewhere, { recursive: true, force: true }); - if (original6 !== undefined) process.env["GITHUB_HEAD_REF"] = original6; - } - }).pipe(Effect.provide(withCwd(elsewhere))); - }); + it.live("walks from an explicit startDir instead of the runtime CWD", () => + Effect.gen(function* () { + // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an + // unrelated dir with no repo, mirroring `supabase --workdir ` run + // from elsewhere. + const project = yield* makeTempDir("git-branch-workdir-"); + yield* writeHead(project, "ref: refs/heads/project-branch\n"); + const elsewhere = yield* makeTempDir("git-branch-cwd-"); + const got = yield* detectGitBranch(project).pipe(Effect.provide(withCwd(elsewhere))); + expect(got).toEqual(Option.some("project-branch")); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/git/git-root.ts b/apps/cli/src/shared/git/git-root.ts index 062fb14c4d..91cf9cd1f9 100644 --- a/apps/cli/src/shared/git/git-root.ts +++ b/apps/cli/src/shared/git/git-root.ts @@ -1,21 +1,20 @@ -import { stat } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { Effect, FileSystem, Option, Path } from "effect"; -export async function findGitRootPath(startPath: string) { - let current = resolve(startPath); +export const findGitRootPath = Effect.fnUntraced(function* (startPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(startPath); - for (;;) { - try { - await stat(resolve(current, ".git")); - return current; - } catch { - // Keep walking until we hit the filesystem root. + while (true) { + // A failed stat means no `.git` here: keep walking until we hit the filesystem root. + if (Option.isSome(yield* fs.stat(path.resolve(current, ".git")).pipe(Effect.option))) { + return Option.some(current); } - const parent = dirname(current); + const parent = path.dirname(current); if (parent === current) { - return undefined; + return Option.none(); } current = parent; } -} +}); diff --git a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts index d227e3b71a..9555b1dd3f 100644 --- a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts +++ b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Path } from "effect"; import { parse } from "yaml"; import { buildIssueUrl, @@ -34,16 +34,17 @@ function isBodyItem(value: unknown): value is IssueFormBodyItem { return isRecord(value); } -function issueTemplateDir() { - return resolve(import.meta.dirname, "../../../../../.github/ISSUE_TEMPLATE"); -} +const issueTemplatePath = Effect.fnUntraced(function* (template: string) { + const path = yield* Path.Path; + return path.resolve(import.meta.dirname, "../../../../../.github/ISSUE_TEMPLATE", template); +}); -function readTemplate(template: string): ReadonlyArray { - const path = resolve(issueTemplateDir(), template); - const parsed = parse(readFileSync(path, "utf8")); +const readTemplate = Effect.fnUntraced(function* (template: string) { + const fs = yield* FileSystem.FileSystem; + const parsed: unknown = parse(yield* fs.readFileString(yield* issueTemplatePath(template))); if (!isRecord(parsed) || !Array.isArray(parsed.body)) return []; return parsed.body.filter(isBodyItem); -} +}); function fieldIds(body: ReadonlyArray) { return body.flatMap((item) => (typeof item.id === "string" ? [item.id] : [])); @@ -75,68 +76,79 @@ function requiredFields(body: ReadonlyArray) { } describe("issue template contract", () => { - it("points to issue form templates that exist", () => { - for (const form of Object.values(issueTemplateContract)) { - expect(existsSync(resolve(issueTemplateDir(), form.template))).toBe(true); - } - }); - - it("keeps issue command field ids aligned with the GitHub issue forms", () => { - for (const form of Object.values(issueTemplateContract)) { - const ids = fieldIds(readTemplate(form.template)); - expect(ids).toEqual(expect.arrayContaining([...form.fields])); - expect(form.fields).toEqual(expect.arrayContaining(ids)); - } - }); - - it("keeps issue command prefilled option values valid for their fields", () => { - for (const form of Object.values(issueTemplateContract)) { - const body = readTemplate(form.template); - for (const [fieldId, values] of Object.entries(form.optionValues)) { - const item = body.find((entry) => entry.id === fieldId); - expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); - expect(optionLabels(item!)).toEqual(expect.arrayContaining([...values])); + it.effect("points to issue form templates that exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const form of Object.values(issueTemplateContract)) { + expect(yield* fs.exists(yield* issueTemplatePath(form.template))).toBe(true); } - } - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps issue command field ids aligned with the GitHub issue forms", () => + Effect.gen(function* () { + for (const form of Object.values(issueTemplateContract)) { + const ids = fieldIds(yield* readTemplate(form.template)); + expect(ids).toEqual(expect.arrayContaining([...form.fields])); + expect(form.fields).toEqual(expect.arrayContaining(ids)); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps issue command prefilled option values valid for their fields", () => + Effect.gen(function* () { + for (const form of Object.values(issueTemplateContract)) { + const body = yield* readTemplate(form.template); + for (const [fieldId, values] of Object.entries(form.optionValues)) { + const item = body.find((entry) => entry.id === fieldId); + expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); + expect(optionLabels(item!)).toEqual(expect.arrayContaining([...values])); + } + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps inferred install methods compatible with the template dropdown", () => + Effect.gen(function* () { + const infer = (execPath: string, env: Record) => + inferIssueInstallMethod({ execPath }).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), + ), + ); + const cases = [ + { userAgent: "pnpm/10.0.0", execPath: "/usr/local/bin/supabase", expected: "pnpm" }, + { userAgent: "npm/11.0.0", execPath: "/usr/local/bin/supabase", expected: "npm" }, + { userAgent: "yarn/4.0.0", execPath: "/usr/local/bin/supabase", expected: "yarn" }, + { userAgent: "bun/1.2.0", execPath: "/usr/local/bin/supabase", expected: "bun" }, + { userAgent: undefined, execPath: "/opt/homebrew/bin/supabase", expected: "brew" }, + { userAgent: undefined, execPath: "/usr/local/bin/supabase", expected: "Other" }, + ] as const; - it("keeps inferred install methods compatible with the template dropdown", () => { - const originalUserAgent = process.env["npm_config_user_agent"]; - const originalInstallMethod = process.env["SUPABASE_INSTALL_METHOD"]; - const cases = [ - { userAgent: "pnpm/10.0.0", execPath: "/usr/local/bin/supabase", expected: "pnpm" }, - { userAgent: "npm/11.0.0", execPath: "/usr/local/bin/supabase", expected: "npm" }, - { userAgent: "yarn/4.0.0", execPath: "/usr/local/bin/supabase", expected: "yarn" }, - { userAgent: "bun/1.2.0", execPath: "/usr/local/bin/supabase", expected: "bun" }, - { userAgent: undefined, execPath: "/opt/homebrew/bin/supabase", expected: "brew" }, - { userAgent: undefined, execPath: "/usr/local/bin/supabase", expected: "Other" }, - ] as const; - - try { - delete process.env["SUPABASE_INSTALL_METHOD"]; for (const testcase of cases) { - if (testcase.userAgent === undefined) { - delete process.env["npm_config_user_agent"]; - } else { - process.env["npm_config_user_agent"] = testcase.userAgent; - } - const value = inferIssueInstallMethod({ execPath: testcase.execPath }); + const value = yield* infer( + testcase.execPath, + testcase.userAgent === undefined ? {} : { npm_config_user_agent: testcase.userAgent }, + ); expect(value).toBe(testcase.expected); expect(issueInstallMethodValues).toContain(value); } - process.env["SUPABASE_INSTALL_METHOD"] = "Docker image"; - expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Docker image"); - - process.env["SUPABASE_INSTALL_METHOD"] = "asdf"; - expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Other"); - } finally { - if (originalUserAgent === undefined) delete process.env["npm_config_user_agent"]; - else process.env["npm_config_user_agent"] = originalUserAgent; - if (originalInstallMethod === undefined) delete process.env["SUPABASE_INSTALL_METHOD"]; - else process.env["SUPABASE_INSTALL_METHOD"] = originalInstallMethod; - } - }); + expect( + yield* infer("/usr/local/bin/supabase", { SUPABASE_INSTALL_METHOD: "Docker image" }), + ).toBe("Docker image"); + expect(yield* infer("/usr/local/bin/supabase", { SUPABASE_INSTALL_METHOD: "asdf" })).toBe( + "Other", + ); + // A blank override falls through to the user agent, like an unset one. + expect( + yield* infer("/usr/local/bin/supabase", { + SUPABASE_INSTALL_METHOD: " ", + npm_config_user_agent: "pnpm/10.0.0", + }), + ).toBe("pnpm"); + }), + ); it("keeps generated issue URLs under the browser-friendly limit", () => { const longField = "x".repeat(4_000); @@ -150,9 +162,13 @@ describe("issue template contract", () => { expect(url.length).toBeLessThanOrEqual(8_000); }); - it("keeps issue form required fields aligned with the command contract", () => { - for (const form of Object.values(issueTemplateContract)) { - expect(requiredFields(readTemplate(form.template))).toEqual([...form.requiredFields]); - } - }); + it.effect("keeps issue form required fields aligned with the command contract", () => + Effect.gen(function* () { + for (const form of Object.values(issueTemplateContract)) { + expect(requiredFields(yield* readTemplate(form.template))).toEqual([ + ...form.requiredFields, + ]); + } + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/issue/issue-url.ts b/apps/cli/src/shared/issue/issue-url.ts index 2c24d5ca6c..ed99976e0c 100644 --- a/apps/cli/src/shared/issue/issue-url.ts +++ b/apps/cli/src/shared/issue/issue-url.ts @@ -1,4 +1,4 @@ -import { Option } from "effect"; +import { Config, Effect, Option } from "effect"; const ISSUE_NEW_URL = "https://github.com/supabase/cli/issues/new"; const MAX_FIELD_LENGTH = 1_500; @@ -134,11 +134,18 @@ function validInstallMethod(value: string): string { return issueInstallMethodValueSet.has(value) ? value : "Other"; } -export function inferIssueInstallMethod(runtimeInfo: { readonly execPath: string }): string { - const explicit = process.env["SUPABASE_INSTALL_METHOD"]?.trim(); - if (explicit) return validInstallMethod(explicit); - - const userAgent = process.env["npm_config_user_agent"]?.toLowerCase(); +export const inferIssueInstallMethod = Effect.fnUntraced(function* (runtimeInfo: { + readonly execPath: string; +}) { + const explicit = (yield* Config.option(Config.string("SUPABASE_INSTALL_METHOD"))).pipe( + Option.map((value) => value.trim()), + Option.filter((value) => value.length > 0), + ); + if (Option.isSome(explicit)) return validInstallMethod(explicit.value); + + const userAgent = Option.getOrUndefined( + yield* Config.option(Config.string("npm_config_user_agent")), + )?.toLowerCase(); if (userAgent?.startsWith("pnpm/")) return "pnpm"; if (userAgent?.startsWith("npm/")) return "npm"; if (userAgent?.startsWith("yarn/")) return "yarn"; @@ -149,4 +156,4 @@ export function inferIssueInstallMethod(runtimeInfo: { readonly execPath: string if (execPath.includes("/node_modules/") || execPath.includes("\\node_modules\\")) return "npm"; return "Other"; -} +}); diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 5028c6f454..1f8cb7c04a 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -4,7 +4,17 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { type ApiClient, makeApiClient, type SupabaseApiConfigError } from "@supabase/api/effect"; -import { Effect, FileSystem, Layer, Option, Predicate, Redacted, Sink, Stream } from "effect"; +import { + ConfigProvider, + Effect, + FileSystem, + Layer, + Option, + Predicate, + Redacted, + Sink, + Stream, +} from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import type { ChildProcess } from "effect/unstable/process"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -768,6 +778,28 @@ export const withEnvVar = ( }), ); +/** + * Pins `values` for the `Config` reads inside `body`, ahead of the ambient provider. Use it + * instead of {@link withEnvVar} for code that reads through `Config`: without a provided + * `ConfigProvider`, Effect's default one snapshots `process.env` on first use, so later + * `process.env` edits are invisible. Pin an empty string to shadow an ambient value. A layer + * inside `body` that installs its own `ConfigProvider` (`processEnvLayer`, + * `isolatedHomeLayer`) replaces the pin, so provide such a layer around the + * `withConfigEnv(...)` call instead. + */ +export const withConfigEnv = ( + values: Readonly>, + body: Effect.Effect, +): Effect.Effect => + body.pipe( + Effect.provide( + ConfigProvider.layerAdd( + ConfigProvider.fromEnvRecord(values, { preserveEmptyStrings: true }), + { asPrimary: true }, + ), + ), + ); + /** * Pins `SUPABASE_SHADOW_CACHE=0` for the calling file so the default-ON cache cannot * flip mocked-spawner suites onto the cache path. Call at module scope (or From 63a2ff5d8577df9a4625db6be251186eecd4abc7 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 12:19:11 +0000 Subject: [PATCH 48/71] fix(cli): state stack destroy --yes instead of self-answering prompt (#6903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** `supabase stack destroy --yes` printed its confirmation as a self-answered question (`… owned data? … [y/N] y`). It now prints a plain statement of what is being destroyed and that Storage uploads are preserved. The interactive prompt is unchanged. ### Before ```mermaid flowchart LR A["stack destroy --yes"] --> B["stderr: Permanently destroy stack … owned data? … [y/N] y"] --> C[destroy] ``` ### After ```mermaid flowchart LR A["stack destroy --yes"] --> B["stderr: Permanently destroying stack … and its owned data. Storage upload files will be preserved."] --> C[destroy] D["stack destroy (interactive TTY)"] --> E["prompt: Permanently destroy stack …? [y/N]"] --> C ``` ### Why In non-TTY output (CI logs, agent transcripts) the `[y/N] y` line looks like a pending prompt that someone answered, even though nothing was asked. supabase/cli#6889 removed the same pattern from bucket seeding during `stack start`. ### What changed - With `--yes`, `stack destroy` writes `Permanently destroying stack at and its owned data. Storage upload files will be preserved.` to stderr instead of calling the prompt helper. - Without `--yes`, an interactive text TTY still sees the same `[y/N]` question. Non-interactive or machine-output runs without `--yes` still fail with the existing "rerun with --yes" error. - The shared `promptYesNo` helper is untouched, so other commands keep their current `--yes` output. - `SIDE_EFFECTS.md` and the handler integration tests cover the new statement and the interactive decline path. ## Linked issue None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../stack/destroy/SIDE_EFFECTS.md | 8 +++-- .../stack/destroy/destroy.handler.ts | 27 ++++++++------- .../stack/destroy/destroy.integration.test.ts | 33 +++++++++++++++++-- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 97c47b8e32..8f7f722738 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -11,9 +11,11 @@ The selectors are mutually exclusive; a missing target fails. Explicit legacy `-o/--output` is rejected in favor of `--output-format`. Interactive text mode asks for confirmation and states that Storage uploads are -preserved. Non-interactive and machine-output runs require `--yes`. Rejection or -cancellation does not open or destroy a stack. Discovery may create/chmod the -registry directory to 0700 but does not launch an owner. +preserved. Non-interactive and machine-output runs require `--yes`. With `--yes` +the command prints no question; stderr states which stack and data are destroyed +and that Storage uploads are preserved. Rejection or cancellation does not open or +destroy a stack. Discovery may create/chmod the registry directory to 0700 but +does not launch an owner. After confirmation the command opens the selected handle and destroys its entire namespace. Destruction may start an owner to clean up a stopped namespace. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 588436ae75..76c17a6e19 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -73,17 +73,22 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( message: "Destroying a stack requires confirmation; rerun with --yes.", suggestion: "Pass --yes when running non-interactively or in a machine-readable format.", }); - const confirmed = yield* promptYesNo( - output, - yes, - `Permanently destroy stack ${target.id} at ${target.projectRoot} and its owned data? Storage upload files will be preserved.`, - false, - ); - if (!confirmed) - return yield* new StackCommandDestroyError({ - reason: "cancelled", - message: "Stack destruction was not confirmed.", - }); + const scope = `stack ${target.id} at ${target.projectRoot} and its owned data`; + const preserved = "Storage upload files will be preserved."; + if (yes) yield* output.raw(`Permanently destroying ${scope}. ${preserved}\n`, "stderr"); + else { + const confirmed = yield* promptYesNo( + output, + false, + `Permanently destroy ${scope}? ${preserved}`, + false, + ); + if (!confirmed) + return yield* new StackCommandDestroyError({ + reason: "cancelled", + message: "Stack destruction was not confirmed.", + }); + } const stack = yield* api .open({ id: target.id, diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 82cb11b65a..402938083a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -12,14 +12,22 @@ import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shar import { stackDestroy } from "./destroy.handler.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); -const fixture = Effect.fn("StackDestroyTest.fixture")(function* (yes: boolean) { +const fixture = Effect.fn("StackDestroyTest.fixture")(function* ( + yes: boolean, + confirm?: { readonly answer: boolean }, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-" }); + const projectRoot = yield* fs.realPath(root); const api = yield* StackApi; const locations = { stateRoot: path.join(root, "stacks"), cacheRoot: path.join(root, "cache") }; const stack = yield* api.create({ ...locations, projectRoot: root, runtime: "native" }); - const output = mockOutput({ interactive: false }); + const output = mockOutput( + confirm === undefined + ? { interactive: false } + : { interactive: true, promptConfirmResponses: [confirm.answer] }, + ); const telemetry = mockTelemetryStateTracked(); const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); const layer = Layer.mergeAll( @@ -27,13 +35,14 @@ const fixture = Effect.fn("StackDestroyTest.fixture")(function* (yes: boolean) { telemetry.layer, settings, stackTargetResolverLayer.pipe(Layer.provide(settings)), - mockTty({ stdinIsTty: false }), + mockTty({ stdinIsTty: confirm !== undefined }), mockStdin(false), Layer.succeed(YesFlag, yes), Layer.succeed(CliArgs, { args: yes ? ["--yes"] : ["--yes=false"] }), ); return { root, + projectRoot, fs, path, api, @@ -59,6 +68,19 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); + it.live("asks on an interactive terminal and keeps the stack when declined", () => + Effect.gen(function* () { + const f = yield* fixture(false, { answer: false }); + const error = yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer), Effect.flip); + expect(error.reason).toBe("cancelled"); + expect(f.output.promptConfirmCalls.map(({ message }) => message)).toEqual([ + `Permanently destroy stack ${f.stack.id} at ${f.projectRoot} and its owned data? Storage upload files will be preserved.`, + ]); + const saved = yield* f.api.discover(f.locations); + expect(saved.map(({ definition }) => definition.id)).toEqual([f.stack.id]); + }).pipe(Effect.provide(live)), + ); + it.live("destroys an offline namespace without affecting a different stack", () => Effect.gen(function* () { const f = yield* fixture(true); @@ -87,6 +109,11 @@ describe("stack destroy", () => { expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ other.id, ]); + expect(f.output.stderrText).toContain( + `Permanently destroying stack ${f.stack.id} at ${f.projectRoot} and its owned data. Storage upload files will be preserved.\n`, + ); + expect(f.output.stderrText).not.toContain("[y/N]"); + expect(f.output.promptConfirmCalls).toEqual([]); expect(f.output.stdoutText).toContain(`Stack ${f.stack.id} destroyed.`); expect(f.telemetry.flushed).toBe(true); }).pipe(Effect.provide(live)), From 6ef331516d2fa75190431ba43d7dad86aa7df5ce Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 12:19:37 +0000 Subject: [PATCH 49/71] test(cli): make reset and stack start integration tests pass on Windows (#6905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** Four `apps/cli` integration tests fail on Windows even on `develop`, but CI stays green because the Windows job only runs part of the `packages/stack` suite. This PR fixes the tests so they pass on Windows and still cover exactly what they covered on Linux and macOS. It also adds both CLI test files to the Windows CI job. No product code changes. ### Why - `db reset` `schema_paths` permission tests: they use `chmod 0o000` to make a schema file unreadable or a directory unlistable. Windows ignores POSIX modes, so the reset succeeds and both tests fail. - `db reset` "seeds an absolute --sql-paths file": the CLI already prints seed paths with forward slashes on every platform (`C:/…/external-seed.sql`). The test expected the backslash form. - `experimental stack start` "…owner cannot reach Docker": the fake `docker` is a `#!/bin/sh` script, added to PATH with `:`. Windows splits PATH on `;` and only resolves `docker` to `docker.exe`, so the stack owner used the real Docker Desktop, started a real stack, and hit the 5 s test timeout. ### What changed - **Permission tests:** still use a real `chmod` wherever POSIX modes are enforced. On Windows, or when running as root (where the tests used to be skipped), they inject a `PermissionDenied` filesystem failure instead. - **Absolute seed test:** expects the forward-slashed absolute path the CLI prints. This changes nothing on POSIX. - **Stack start:** the original daemon-refusal test is kept unchanged except for a new name and a skip on Windows. A new test sets an empty PATH, so no Docker CLI can be found, and checks the same outcome on every platform: `runtime` error, Docker suggestion, and no stack left registered. - **CI:** the `test-stack-ports` job gets a Windows-only step that runs `reset.integration.test.ts` and `start.integration.test.ts`. ### Reviewer notes - These changes were checked on macOS only. The new Windows CI step is the first real Windows run. - The GitHub `windows-latest` runner has no Docker Desktop. A regression that lets the real `docker.exe` back into the no-CLI test will therefore only show up on a machine with Docker installed. - Other tests use the same POSIX-only setup and would also fail on Windows: both `destroy.runtime-unavailable` integration tests and the `chmod` cases in `sql-files-glob.unit.test.ts`. They are out of scope here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/test.yml | 10 ++ .../db/reset/reset.integration.test.ts | 141 +++++++++++------- .../stack/start/start.integration.test.ts | 135 ++++++++++++----- 3 files changed, 193 insertions(+), 93 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 391542b18e..3c934ecae5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -164,6 +164,16 @@ jobs: src/HttpProxy.integration.test.ts --passWithNoTests=false + # Windows-only CLI subset: paths, PATH lookup, and permission semantics diverge there. + - name: Run CLI integration tests + if: runner.os == 'Windows' + working-directory: apps/cli + run: >- + pnpm test:integration + src/commands/db/reset/reset.integration.test.ts + src/commands/experimental/stack/start/start.integration.test.ts + --passWithNoTests=false + test-summary: if: | always() && diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 2252d027c2..dca6542b48 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -2983,9 +2983,48 @@ describe("db reset", () => { }, ); - const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + // Windows ignores POSIX modes and root bypasses them; both get an injected failure instead. + const posixModesEnforced = process.platform !== "win32" && process.getuid?.() !== 0; + + /** Denies `method` on `target` for the enclosing scope and returns the FileSystem to run with. */ + const denyAccess = Effect.fnUntraced(function* ( + target: string, + method: "readFileString" | "readDirectory", + restoreMode: number, + ) { + const fs = yield* FileSystem.FileSystem; + if (posixModesEnforced) { + yield* Effect.acquireRelease(fs.chmod(target, 0o000), () => + fs.chmod(target, restoreMode).pipe(Effect.orDie), + ); + return fs; + } + const denied = (p: string) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor: p, + description: "permission denied", + }), + ); + const overridden: FileSystem.FileSystem = + method === "readFileString" + ? { + ...fs, + readFileString: (p, encoding) => + p === target ? denied(p) : fs.readFileString(p, encoding), + } + : { + ...fs, + readDirectory: (p, options) => + p === target ? denied(p) : fs.readDirectory(p, options), + }; + return overridden; + }); - it.live.skipIf(isRoot)( + it.live( "does not attach the schema-file suggestion when a schema file cannot be READ on an experimental remote reset", () => { const { layer, conn } = setup(tmp.current, { @@ -2999,35 +3038,31 @@ describe("db reset", () => { const schemaFile = path.join(tmp.current, "supabase", "schemas", "01_users.sql"); yield* fs.makeDirectory(path.dirname(schemaFile), { recursive: true }); yield* fs.writeFileString(schemaFile, "create table schema_users ();"); - yield* Effect.acquireUseRelease( - fs.chmod(schemaFile, 0o000), - () => - Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).not.toContain("See schema file"); - expect(Cause.findErrorOption(exit.cause)).not.toEqual( - Option.some( - expect.objectContaining({ - suggestion: expect.stringContaining("See schema file"), - }), - ), - ); - } - expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); - }), - () => fs.chmod(schemaFile, 0o644), + const deniedFs = yield* denyAccess(schemaFile, "readFileString", 0o644); + const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provideService(FileSystem.FileSystem, deniedFs), + Effect.provide(layer), + Effect.exit, ); - }).pipe(Effect.provide(BunServices.layer)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("failed to open migration file"); + expect(causeText).not.toContain("See schema file"); + expect(Cause.findErrorOption(exit.cause)).not.toEqual( + Option.some( + expect.objectContaining({ + suggestion: expect.stringContaining("See schema file"), + }), + ), + ); + } + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }, ); - it.live.skipIf(isRoot)( + it.live( "fails an experimental remote reset (without silently succeeding) when a matched schema_paths directory cannot be walked", () => { const { layer, conn } = setup(tmp.current, { @@ -3044,33 +3079,28 @@ describe("db reset", () => { path.join(schemasDir, "01_users.sql"), "create table schema_users ();", ); - yield* Effect.acquireUseRelease( - fs.chmod(schemasDir, 0o000), - () => - Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to walk matched directory"); - expect(causeText).not.toContain("See schema file"); - expect(Cause.findErrorOption(exit.cause)).not.toEqual( - Option.some( - expect.objectContaining({ - suggestion: expect.stringContaining("See schema file"), - }), - ), - ); - } - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); - }), - () => fs.chmod(schemasDir, 0o755), + const deniedFs = yield* denyAccess(schemasDir, "readDirectory", 0o755); + const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provideService(FileSystem.FileSystem, deniedFs), + Effect.provide(layer), + Effect.exit, ); - }).pipe(Effect.provide(BunServices.layer)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("failed to walk matched directory"); + expect(causeText).not.toContain("See schema file"); + expect(Cause.findErrorOption(exit.cause)).not.toEqual( + Option.some( + expect.objectContaining({ + suggestion: expect.stringContaining("See schema file"), + }), + ), + ); + } + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }, ); @@ -3304,7 +3334,10 @@ describe("db reset", () => { linked: true, sqlPaths: [absSeed], }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + // Seed paths are reported forward-slashed on every platform, drive letter included. + expect(out.stderrText).toContain( + `Seeding data from ${absSeed.replaceAll(path.sep, "/")}...`, + ); }).pipe(Effect.provide(BunServices.layer)); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index b04eaa8b21..2657600613 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -31,6 +31,7 @@ import { import { mockCommandSettings, mockTelemetryStateTracked, + withEnvVar, } from "../../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { containerEngineSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; @@ -601,8 +602,10 @@ describe("experimental stack start", () => { expect(text.stdoutText).not.toContain("GraphQL"); expect(text.stdoutText).toMatch(/Secret +│ \S+ +│/u); expect(text.stdoutText).toMatch(/rest +│ running · healthy · lazy +│/u); + // Windows temp paths contain `\` and `~`, so the PowerShell pointer quotes the workdir. + const workdir = process.platform === "win32" ? `'${root}'` : root; expect(text.stdoutText).toContain( - `Runtime: native\nRun supabase status --env --workdir ${root} --stack 'feature demo' to export these values as environment variables.\n`, + `Runtime: native\nRun supabase status --env --workdir ${workdir} --stack 'feature demo' to export these values as environment variables.\n`, ); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1128,52 +1131,106 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("leaves no stack registered when a new stack's owner cannot reach Docker", () => + // The `#!/bin/sh` shim is never picked up on Windows, which only resolves `docker.exe` on PATH. + it.live.skipIf(process.platform === "win32")( + "leaves no stack registered when a new stack's owner cannot reach the Docker daemon", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-create-failure-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "create-failure"\n', + ); + yield* fs.makeDirectory(`${root}/bin`); + yield* fs.writeFileString( + `${root}/bin/docker`, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at unix:///shim/docker.sock. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* fs.chmod(`${root}/bin/docker`, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${root}/bin:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + const output = mockOutput(); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.succeed({ projectRoot: root, runtime: "docker" as const, hostRunning: false }), + }); + const api = stackApiLayer.pipe(Layer.provide(BunServices.layer)); + + const error = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), + ); + expect(error.message).toContain("Cannot connect to the Docker daemon"); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.reason).toBe("runtime"); + expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); + } + expect(output.stderrText).not.toContain("Failed to stop"); + + const stacks = yield* StackApi.pipe( + Effect.flatMap((stackApi) => + stackApi.discover({ stateRoot: `${root}/.supabase/stacks` }), + ), + Effect.provide(api), + ); + expect(stacks).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("leaves no stack registered when a new stack's owner finds no Docker CLI", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-create-failure-" }); yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "create-failure"\n'); - yield* fs.makeDirectory(`${root}/bin`); - yield* fs.writeFileString( - `${root}/bin/docker`, - "#!/bin/sh\necho 'Cannot connect to the Docker daemon at unix:///shim/docker.sock. Is the docker daemon running?' >&2\nexit 1\n", - ); - yield* fs.chmod(`${root}/bin/docker`, 0o755); - // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. - const originalPath = process.env.PATH; - // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. - process.env.PATH = `${root}/bin:${originalPath ?? ""}`; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. - process.env.PATH = originalPath; - }), - ); - const output = mockOutput(); - const target = Layer.succeed(StackTargetResolver, { - resolve: () => - Effect.succeed({ projectRoot: root, runtime: "docker" as const, hostRunning: false }), - }); - const api = stackApiLayer.pipe(Layer.provide(BunServices.layer)); + // An empty PATH hides any installed Docker CLI on every platform. + yield* fs.makeDirectory(`${root}/empty-bin`); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- Windows names the variable `Path`; the detached owner inherits it. + const envKeys = Object.keys(process.env); + const pathKey = envKeys.find((key) => key.toUpperCase() === "PATH") ?? "PATH"; + yield* withEnvVar( + pathKey, + `${root}/empty-bin`, + Effect.gen(function* () { + const output = mockOutput(); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.succeed({ projectRoot: root, runtime: "docker" as const, hostRunning: false }), + }); + const api = stackApiLayer.pipe(Layer.provide(BunServices.layer)); - const error = yield* stackStart(flags()).pipe( - Effect.flip, - Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), - ); - expect(error.message).toContain("Cannot connect to the Docker daemon"); - expect(error).toBeInstanceOf(StackCommandStartError); - if (error instanceof StackCommandStartError) { - expect(error.reason).toBe("runtime"); - expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); - } - expect(output.stderrText).not.toContain("Failed to stop"); + const error = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), + ); + expect(error.message).toContain("docker ps"); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.reason).toBe("runtime"); + expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); + } + expect(output.stderrText).not.toContain("Failed to stop"); - const stacks = yield* StackApi.pipe( - Effect.flatMap((stackApi) => stackApi.discover({ stateRoot: `${root}/.supabase/stacks` })), - Effect.provide(api), + const stacks = yield* StackApi.pipe( + Effect.flatMap((stackApi) => + stackApi.discover({ stateRoot: `${root}/.supabase/stacks` }), + ), + Effect.provide(api), + ); + expect(stacks).toEqual([]); + }), ); - expect(stacks).toEqual([]); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); From 2d7609dd9d655ad742be557819e923432b2e1244 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 12:26:53 +0000 Subject: [PATCH 50/71] fix(cli): treat the stack database --db-url as local (#6904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** Under the experimental stack backend, `supabase db pull --db-url "$DB_URL"` with the `DB_URL` printed by `supabase stack status --env` failed with a TLS error unless `sslmode=disable` was appended. The CLI now recognizes that URL as the local stack database and connects in plaintext, like `--local`. Remote URLs keep requiring TLS. ### Before ```mermaid flowchart LR A["--db-url from
stack status --env"] --> B{"port = [db].port
or shadow_port?"} B -- yes --> L["local: plaintext"] B -- "no (stack port)" --> R["remote: TLS only"] R --> F["tls error: server does
not support SSL"] ``` ### After ```mermaid flowchart LR A["--db-url"] --> B{"port = [db].port
or shadow_port?"} B -- yes --> L["local: plaintext"] B -- no --> C{"stack backend and
host:port = running
stack SQL endpoint?"} C -- yes --> L C -- "no / lookup fails" --> R["remote: TLS only"] ``` ### Why ``` failed to connect to postgres: ... tls error (The server does not support SSL connections) ``` The `--db-url` resolver classified a URL as local only when its port matched `[db].port` / `[db].shadow_port`. The stack backend publishes Postgres on a runtime-assigned port, so the stack's own URL was classified remote, and the driver layer makes an unset/`prefer` `sslmode` TLS-only for remote targets (no TLS-to-plaintext downgrade). `db dump` was unaffected because `pg_dump`'s libpq `prefer` falls back to plaintext; `db pull`, `gen types`, `inspect`, and other driver-based commands all hit the error. ### What changed - Under the stack backend, a `--db-url` whose host and port equal the running project stack's SQL endpoint is a local target (plaintext, no role step-down, local connect hints), matching what `db dump` and `test db` already expect for stack URLs. - A passwordless stack URL is filled from the stack's saved database password rather than `[db].password`. - If the stack is not registered, not running, or the lookup fails, the target stays remote; an explicit `sslmode` (e.g. `require`) is still honored. - The db pull `SIDE_EFFECTS.md` documents the classification. Probe-and-fallback for loopback hosts and emitting `sslmode=disable` from `stack status --env` were considered and rejected: the first relaxes the no-downgrade policy for every command, the second leaves hand-typed stack URLs broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../db-config.integration.test.ts | 217 +++++++++++++++++- .../src/command-internal/db-config.layer.ts | 65 ++++-- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 3 + 3 files changed, 262 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 02ce30b130..9e8a09e3ae 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; +import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { vi } from "vitest"; // Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. @@ -41,10 +42,12 @@ import { } from "./global-flags.ts"; import { DebugLogger } from "./debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; -import { dbConfigLayer } from "./db-config.layer.ts"; +import { dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConfigFlags } from "./db-config.types.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; +import { StackApi } from "./stack-api.ts"; +import { stackBackendLayer } from "./stack-backend.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver // builds with simple ambient stubs. The `--linked` sub-flow (login-role, @@ -66,6 +69,7 @@ function buildResolver( readonly poolerHost?: string; readonly dbConnection?: Layer.Layer; readonly configEnv?: Record; + readonly stackApi?: Layer.Layer; } = {}, ) { const deps = Layer.mergeAll( @@ -102,7 +106,9 @@ function buildResolver( ), ), ); - return dbConfigLayer.pipe(Layer.provide(deps)); + return opts.stackApi === undefined + ? dbConfigLayer.pipe(Layer.provide(deps)) + : dbConfigResolverLayer.pipe(Layer.provide(Layer.merge(deps, opts.stackApi))); } function withWorkdir(toml?: string) { @@ -306,6 +312,22 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); + it.effect("db-url mode: a multi-host url stays remote even when its primary is local", () => { + const dir = withWorkdir(); + return resolve( + dir, + dbUrlFlags("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + ).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn.fallbacks).toEqual([{ host: "db.example.com", port: 5432 }]); + expect(r.isLocal).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("db-url mode: a passwordless local url fills the password from config", () => { const dir = withWorkdir(["[db]", "port = 54322", 'password = "hunter2"', ""].join("\n")); return resolve(dir, dbUrlFlags("postgres://postgres@127.0.0.1:54322/postgres")).pipe( @@ -397,6 +419,195 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); +describe("dbConfigResolver (db-url under the stack backend)", () => { + const STACK_SQL_PORT = 54329; + const stackUrl = (port: number) => + `postgresql://supabase_admin:postgres@127.0.0.1:${port}/postgres?connect_timeout=10`; + + type StackState = "running" | "stopped" | "unregistered"; + + const projectStackApi = (root: string, state: StackState) => { + const unused = Effect.die("unused by the resolver"); + const database: DatabaseInstance = { + id: "database-primary", + service: "database", + start: unused, + ready: unused, + stop: unused, + restart: () => unused, + destroy: unused, + prepare: unused, + status: Effect.succeed({ + id: "database-primary", + endpoints: + state === "running" + ? [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: STACK_SQL_PORT }] + : [], + config: { + service: "database", + config: { + version: "17.6.1.173", + databasePassword: Redacted.make("stack-password"), + jwtSecret: Redacted.make("secret"), + jwtExpiry: 3600, + }, + endpoints: {}, + }, + lifecycle: state === "running" ? "running" : "stopped", + health: state === "running" ? "healthy" : undefined, + error: undefined, + cleanupError: undefined, + exit: undefined, + currentOperation: undefined, + launchId: undefined, + intentRevision: 0, + wakeEnabled: state === "running", + registered: true, + }), + followStatus: Stream.empty, + logs: Stream.empty, + credentials: () => unused, + saveSnapshot: () => unused, + restoreSnapshot: () => unused, + resetData: unused, + }; + const stack: Stack = { + id: "b".repeat(64), + services: { create: () => unused, get: () => Effect.succeed(database), list: unused }, + credentials: { get: unused }, + composition: { + plan: () => unused, + describe: Effect.succeed({ + members: [{ id: database.id, activation: "eager" }], + dependencies: [], + }), + supabase: () => unused, + configure: () => unused, + start: unused, + stop: unused, + restart: unused, + }, + stop: unused, + destroy: unused, + commands: { run: () => unused }, + }; + return Layer.succeed(StackApi, { + create: () => unused, + open: () => Effect.succeed(stack), + discover: () => unused, + find: () => + Effect.succeed( + state !== "unregistered" + ? Option.some({ + definition: { + id: stack.id, + identity: { projectRoot: root, branchContext: "main", stackName: "default" }, + runtime: "native" as const, + instances: [], + lifetime: "detached" as const, + composition: { members: [], dependencies: [] }, + ports: [], + }, + host: undefined, + }) + : Option.none(), + ), + }); + }; + + const resolveOnStack = ( + dir: string, + url: string, + stackApi: Layer.Layer = projectStackApi(dir, "running"), + ) => + resolve(dir, dbUrlFlags(url), { stackApi }).pipe( + Effect.provide(stackBackendLayer("stack")), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + + it.effect("treats the url printed by `stack status --env` as the local database", () => + Effect.gen(function* () { + const resolved = yield* resolveOnStack(withWorkdir(), stackUrl(STACK_SQL_PORT)); + expect(resolved.isLocal).toBe(true); + }), + ); + + it.effect("fills a passwordless stack url from the stack's credentials, not [db].password", () => + Effect.gen(function* () { + const dir = withWorkdir(["[db]", 'password = "config-password"', ""].join("\n")); + const resolved = yield* resolveOnStack( + dir, + `postgresql://postgres@127.0.0.1:${STACK_SQL_PORT}/postgres`, + ); + expect(resolved.isLocal).toBe(true); + expect(resolved.conn.password).toBe("stack-password"); + }), + ); + + it.effect("keeps a loopback url on another port remote so it still requires TLS", () => + Effect.gen(function* () { + const resolved = yield* resolveOnStack(withWorkdir(), stackUrl(STACK_SQL_PORT + 1)); + expect(resolved.isLocal).toBe(false); + }), + ); + + it.effect("fills from the stack's credentials when the stack port is also [db].port", () => + Effect.gen(function* () { + const dir = withWorkdir( + ["[db]", `port = ${STACK_SQL_PORT}`, 'password = "config-password"', ""].join("\n"), + ); + const resolved = yield* resolveOnStack( + dir, + `postgresql://postgres@127.0.0.1:${STACK_SQL_PORT}/postgres`, + ); + expect(resolved.isLocal).toBe(true); + expect(resolved.conn.password).toBe("stack-password"); + }), + ); + + for (const state of ["unregistered", "stopped"] as const) { + it.effect(`resolves the stack url as remote when the project stack is ${state}`, () => + Effect.gen(function* () { + const dir = withWorkdir(); + const resolved = yield* resolveOnStack( + dir, + stackUrl(STACK_SQL_PORT), + projectStackApi(dir, state), + ); + expect(resolved.isLocal).toBe(false); + }), + ); + } + + it.effect("keeps a multi-host url remote even when its primary is the stack endpoint", () => + Effect.gen(function* () { + const resolved = yield* resolveOnStack( + withWorkdir(), + `postgresql://postgres:pw@127.0.0.1:${STACK_SQL_PORT},db.example.com:5432/postgres`, + ); + expect(resolved.conn.fallbacks).toEqual([{ host: "db.example.com", port: 5432 }]); + expect(resolved.isLocal).toBe(false); + }), + ); + + it.effect("does not consult the stack for a non-loopback host", () => + Effect.gen(function* () { + const untouchedStackApi = Layer.succeed(StackApi, { + create: () => Effect.die("unexpected stack create"), + open: () => Effect.die("unexpected stack open"), + discover: () => Effect.die("unexpected stack discover"), + find: () => Effect.die("unexpected stack lookup"), + }); + const resolved = yield* resolveOnStack( + withWorkdir(), + `postgresql://postgres:pw@db.example.com:${STACK_SQL_PORT}/postgres`, + untouchedStackApi, + ); + expect(resolved.isLocal).toBe(false); + }), + ); +}); + describe("dbConfigResolver (linked config ordering)", () => { it.effect( "validates the ref-merged config before any network work (Go ParseDatabaseConfig order)", diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index de5d68d5da..90f101b602 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -80,6 +80,16 @@ function isLocalDatabase( return host === localHost && (port === dbPort || port === shadowPort); } +/** Stack databases listen on loopback, so only loopback or services-hostname URLs can match one. */ +function mayBeStackDatabaseHost(host: string, localHost: string): boolean { + return ( + host === localHost || + host === "localhost" || + host === "::1" || + (net.isIPv4(host) && host.startsWith("127.")) + ); +} + /** Best-effort TCP reachability probe with a 5s timeout. */ const tcpReachable = (host: string, port: number): Effect.Effect => Effect.callback((resume) => { @@ -379,7 +389,8 @@ export const resolveLinkedConn = Effect.fnUntraced(function* ( return poolerConn.value; }); -const dbConfigResolverLayer = Layer.effect( +/** The resolver without its `StackApi`, for callers that supply their own stack boundary. */ +export const dbConfigResolverLayer = Layer.effect( DbConfigResolver, Effect.gen(function* () { const cliSettings = yield* CommandSettings; @@ -456,6 +467,13 @@ const dbConfigResolverLayer = Layer.effect( ambientLayer; void _ambientCoverageCheck; + // `resolve`'s R is `never`, so capture StackApi at layer build. + const stackDatabaseConn = stackLocalDatabaseConn.pipe( + Effect.provideService(CommandSettings, cliSettings), + Effect.provideService(StackApi, stackApi), + Effect.provideService(Path.Path, path), + ); + const resolve = (flags: DbConfigFlags) => Effect.gen(function* () { const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; @@ -492,21 +510,34 @@ const dbConfigResolverLayer = Layer.effect( }), ); } - const isLocal = isLocalDatabase( - conn.host, - localHost, - conn.port, - tomlValues.port, - tomlValues.shadowPort, - ); - // A local direct URL fills an empty password from the local `[db].password` config, - // so a passwordless local DSN like `postgresql://postgres@127.0.0.1:54322/postgres` + // A multi-host URL stays remote: local disables TLS for every fallback host as well. + const singleHost = conn.fallbacks === undefined; + const legacyLocal = + singleHost && + isLocalDatabase( + conn.host, + localHost, + conn.port, + tomlValues.port, + tomlValues.shadowPort, + ); + // The stack backend publishes its database on a runtime-assigned port rather than + // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. + const stackConn = + singleHost && + (yield* currentStackBackend).kind === "stack" && + mayBeStackDatabaseHost(conn.host, localHost) + ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) + : undefined; + const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; + const isLocal = legacyLocal || onStack; + // A local direct URL fills an empty password from the local database's own + // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` // still authenticates. + const localPassword = onStack ? stackConn.password : tomlValues.password; return { conn: - isLocal && conn.password.length === 0 - ? { ...conn, password: tomlValues.password } - : conn, + isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, isLocal, }; } @@ -575,13 +606,7 @@ const dbConfigResolverLayer = Layer.effect( }); const backend = yield* currentStackBackend; if (backend.kind === "stack") { - // `resolve`'s R is `never`, so capture StackApi at layer build. - const conn = yield* stackLocalDatabaseConn.pipe( - Effect.provideService(CommandSettings, cliSettings), - Effect.provideService(StackApi, stackApi), - Effect.provideService(Path.Path, path), - ); - return { conn, isLocal: true }; + return { conn: yield* stackDatabaseConn, isLocal: true }; } return { conn: { diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 966fc61738..01499ce47e 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -23,6 +23,9 @@ cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root and uses an automatically assigned port; `db.shadow_port` applies only to the legacy backend. Migra (`--diff-engine migra`) is rejected in stack mode. +A `--db-url` whose host and port match the running project stack's SQL endpoint (the `DB_URL` +from `stack status --env`) is a local target and connects in plaintext, like `--local`; a remote +or multi-host `--db-url` without an explicit `sslmode` still requires TLS. Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under From 4371ed369d5f7f806e6905e0ec9ba79eb831c78b Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 12:27:54 +0000 Subject: [PATCH 51/71] fix(stack): load function deno.json files as Deno config (#6907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR With `[experimental] stack = true`, the function generated by `supabase functions new` failed to boot and answered `503 BOOT_ERROR`. The stack handed the function's own `deno.json` to Edge Runtime as a plain import map, which does not expand `jsr:` subpaths. It now lets Edge Runtime load that file as the function's Deno config, matching `functions serve` without the stack. ## Before ```mermaid flowchart LR A["functions/hello/deno.json"] --> B["context.importMapPath"] B --> C["plain import map
no jsr: subpaths, no comments"] C --> D["503 BOOT_ERROR"] ``` ## After ```mermaid flowchart LR A["functions/hello/deno.json"] --> B{"nearest Deno config
of the entrypoint?"} B -->|yes| C["omitted, Edge Runtime
loads it as Deno config"] C --> D["200"] B -->|no, e.g. import_map.json| E["context.importMapPath
as before"] ``` | Function | Before | After | | --- | --- | --- | | `functions new` template (`import_map = "./functions/hello/deno.json"`) | 503 `BOOT_ERROR` | 200 | | template with the `import_map` line removed (auto-detected `deno.json`) | 503 `BOOT_ERROR` | 200 | | `deno.jsonc` containing a comment | 503 `BOOT_ERROR` | 200 | | plain `import_map.json` | 200 | 200 | | no config file | 200 | 200 | Same on the docker and native runtimes. ## Why Edge Runtime log for the template: ``` worker boot error: failed to bootstrap runtime: failed to create the graph: Relative import path "@supabase/functions-js/edge-runtime.d.ts" not prefixed with / or ./ or ../ and not in import map ``` Edge Runtime reads `context.importMapPath` with a plain JSON import-map loader. That path skips Deno config handling: the `jsr:`/`npm:` subpath expansion, JSONC comments, and workspace import merging. When the option is omitted, Deno discovers the nearest `deno.json`/`deno.jsonc` from the entrypoint and loads it as a config file. ## What changed - The Functions bootstrap passes an import map only when Edge Runtime would not already discover it as the entrypoint's nearest regular `deno.json`/`deno.jsonc` inside the project. - Custom import maps (`import_map.json`, or a config other than the nearest one) are still passed explicitly, so they keep overriding discovery. - A configured `deno.json(c)` that is not the nearest config (for example `functions/_shared/deno.jsonc`) is still loaded as a plain import map, which drops comments and `jsr:`/`npm:` subpath imports. The bootstrap now logs a one-time warning per function for that case. - Regression coverage: resolver integration cases, an Edge Runtime boot test, and a `functions new` → `stack start` → request E2E on docker and native. ## Linked issue None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../functions/new/new.stack.e2e.test.ts | 170 ++++++++++++++++++ .../functions/generated/serve-main-bundle.ts | 2 +- .../serve-main-resolver.integration.test.ts | 116 ++++++++++++ .../src/functions/serve-main-resolver.ts | 50 +++++- packages/stack/src/functions/serve.main.ts | 20 ++- .../services/Functions.integration.test.ts | 156 +++++++++++++++- 6 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/commands/functions/new/new.stack.e2e.test.ts diff --git a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts new file mode 100644 index 0000000000..d3c24be7c5 --- /dev/null +++ b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts @@ -0,0 +1,170 @@ +// Golden path for a stack-mode project: `functions new hello` scaffolds the template, `stack +// start` boots it, and a real request proves Edge Runtime resolves the template's import map. +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { homedir } from "node:os"; + +import { makeTempHome, runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; + +const nativeSupported = + (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || + (process.platform === "darwin" && process.arch === "arm64"); + +const NEW_TIMEOUT_MS = 60_000; +const START_TIMEOUT_MS = 15 * 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +// The template's first request resolves `jsr:@supabase/functions-js` and `npm:@supabase/server` +// over the network, on top of waking the lazily-started Functions member. +const INVOKE_TIMEOUT_MS = 5 * 60_000; +const SETUP_MARGIN_MS = 60_000; + +const minimalConfig = `project_id = "functions-new-stack-e2e" + +[experimental] +stack = true + +[api] +enabled = true + +[auth] +enabled = false + +[db.pooler] +enabled = false + +[edge_runtime] +enabled = true + +[realtime] +enabled = false + +[storage] +enabled = false + +[studio] +enabled = false + +[analytics] +enabled = false + +[local_smtp] +enabled = false +`; + +const StartResultSchema = Schema.Struct({ id: Schema.String }); +const StackEnvSchema = Schema.Struct({ API_URL: Schema.String, PUBLISHABLE_KEY: Schema.String }); +const HelloResponseSchema = Schema.Struct({ message: Schema.String }); + +const layer = Layer.mergeAll(BunServices.layer, FetchHttpClient.layer); + +describe("functions new (stack e2e)", () => { + for (const runtime of ["native", "docker"] as const) { + if (runtime === "native" && !nativeSupported) continue; + + it.live( + `serves the generated hello Function through ${runtime} stack start`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Linux Edge Runtime overlays /tmp with a private worker filesystem, so native functions need a visible host path. + const projectDir = yield* fs.makeTempDirectoryScoped({ + ...(runtime === "native" && process.platform === "linux" + ? { directory: homedir() } + : {}), + prefix: `functions-new-stack-${runtime}-`, + }); + const home = makeTempHome(); + yield* Effect.addFinalizer(() => Effect.sync(() => home[Symbol.dispose]())); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + minimalConfig, + ); + + const created = yield* runSupabaseEffect( + ["functions", "new", "hello", "--output-format", "json"], + { cwd: projectDir, home: home.dir, exitTimeoutMs: NEW_TIMEOUT_MS }, + ); + expect(created.exitCode, `stdout:\n${created.stdout}\nstderr:\n${created.stderr}`).toBe( + 0, + ); + + let stackId: string | undefined; + yield* Effect.addFinalizer(() => + stackId === undefined + ? Effect.void + : runSupabaseEffect(["stack", "destroy", "--stack-id", stackId, "--yes"], { + cwd: projectDir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }).pipe( + Effect.orDie, + Effect.flatMap((destroyed) => + destroyed.exitCode === 0 + ? Effect.void + : Effect.die( + `stack destroy exited ${destroyed.exitCode}\nstdout:\n${destroyed.stdout}\nstderr:\n${destroyed.stderr}`, + ), + ), + ), + ); + + const started = yield* runSupabaseEffect( + ["stack", "start", "--runtime", runtime, "--output-format", "json"], + { + cwd: projectDir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: START_TIMEOUT_MS, + }, + ); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe( + 0, + ); + const startResult = yield* Schema.decodeEffect(Schema.fromJsonString(StartResultSchema))( + started.stdout.trim(), + ); + stackId = startResult.id; + + const status = yield* runSupabaseEffect( + ["stack", "status", "--env", "--stack-id", stackId, "--output-format", "json"], + { + cwd: projectDir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }, + ); + expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + const env = yield* Schema.decodeEffect(Schema.fromJsonString(StackEnvSchema))( + status.stdout, + ); + + const http = yield* HttpClient.HttpClient; + const request = yield* HttpClientRequest.post(`${env.API_URL}/functions/v1/hello`, { + headers: { apikey: env.PUBLISHABLE_KEY }, + }).pipe(HttpClientRequest.bodyJson({ name: "e2e" })); + const response = yield* http.execute(request).pipe(Effect.timeout(INVOKE_TIMEOUT_MS)); + const text = yield* response.text; + expect(response.status, `response body:\n${text}`).toBe(200); + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(HelloResponseSchema))( + text, + ); + expect(decoded).toEqual({ message: "Hello e2e!" }); + }).pipe(Effect.provide(layer)), + { + timeout: + NEW_TIMEOUT_MS + + START_TIMEOUT_MS + + CLEANUP_TIMEOUT_MS + + INVOKE_TIMEOUT_MS + + CLEANUP_TIMEOUT_MS + + SETUP_MARGIN_MS, + }, + ); + } +}); diff --git a/packages/stack/src/functions/generated/serve-main-bundle.ts b/packages/stack/src/functions/generated/serve-main-bundle.ts index 543283d181..9514b4407e 100644 --- a/packages/stack/src/functions/generated/serve-main-bundle.ts +++ b/packages/stack/src/functions/generated/serve-main-bundle.ts @@ -1,3 +1,3 @@ // Generated by packages/stack/scripts/generate-functions-bootstrap.ts; run `pnpm generate`. /** Bundled Edge Runtime main service used when a Functions creation has no bootstrap. */ -export const defaultFunctionsBootstrap = "var l2=Object.defineProperty;var uo=(e,t)=>{for(var n in t)l2(e,n,{get:t[n],enumerable:!0})};var K=(e,t)=>{switch(t.length){case 0:return e;case 1:return t[0](e);case 2:return t[1](t[0](e));case 3:return t[2](t[1](t[0](e)));case 4:return t[3](t[2](t[1](t[0](e))));case 5:return t[4](t[3](t[2](t[1](t[0](e)))));case 6:return t[5](t[4](t[3](t[2](t[1](t[0](e))))));case 7:return t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))));case 8:return t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e))))))));case 9:return t[8](t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))))));default:{let n=e;for(let r=0,o=t.length;rt(n,...arguments)};switch(e){case 0:case 1:throw new RangeError(`Invalid arity ${e}`);case 2:return function(n,r){return arguments.length>=2?t(n,r):function(o){return t(o,n)}};case 3:return function(n,r,o){return arguments.length>=3?t(n,r,o):function(s){return t(s,n,r)}};default:return function(){if(arguments.length>=e)return t.apply(this,arguments);let n=arguments;return function(r){return t(r,...n)}}}};var _=e=>e;var Le=e=>()=>e,cn=Le(!0),Au=Le(!1),Ag=Le(null),fo=Le(void 0),Rr=fo;function Id(e,...t){return K(e,t)}function wd(e,t,n,r,o,s,i,a,c){switch(arguments.length){case 1:return e;case 2:return function(){return t(e.apply(this,arguments))};case 3:return function(){return n(t(e.apply(this,arguments)))};case 4:return function(){return r(n(t(e.apply(this,arguments))))};case 5:return function(){return o(r(n(t(e.apply(this,arguments)))))};case 6:return function(){return s(o(r(n(t(e.apply(this,arguments))))))};case 7:return function(){return i(s(o(r(n(t(e.apply(this,arguments)))))))};case 8:return function(){return a(i(s(o(r(n(t(e.apply(this,arguments))))))))};case 9:return function(){return c(a(i(s(o(r(n(t(e.apply(this,arguments)))))))))}}}function It(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),o}}function di(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),t.set(o,o),o}}var Cu=e=>{let t=new Set(Reflect.ownKeys(e));if(e.constructor===Object)return t;e instanceof Error&&t.delete(\"stack\");let n=Object.getPrototypeOf(e),r=n;for(;r!==null&&r!==Object.prototype;){let o=Reflect.ownKeys(r);for(let s=0;sku(e)&&t in e),$t=l(2,(e,t)=>M(e,\"_tag\")&&e._tag===t);function I0(e){return e instanceof Error}function $o(e){return M(e,Symbol.iterator)||$n(e)}var be=\"~effect/interfaces/Hash\",H=e=>{switch(typeof e){case\"number\":return On(e);case\"bigint\":return Ue(e.toString(10));case\"boolean\":return Ue(String(e));case\"symbol\":return Ue(String(e));case\"string\":return Ue(e);case\"undefined\":return Ue(\"undefined\");case\"function\":case\"object\":{if(e===null)return Ue(\"null\");if(e instanceof Date)return Number.isNaN(e.getTime())?Ue(\"Invalid Date\"):Ue(e.toISOString());if(e instanceof RegExp)return Ue(e.toString());{if(La.has(e))return Ad(e);if(Rg.has(e))return Rg.get(e);let t=g2(e,()=>p2(e)?e[be]():typeof e==\"function\"?Ad(e):e instanceof DataView?$a(new Uint8Array(e.buffer,e.byteOffset,e.byteLength)):Array.isArray(e)||ArrayBuffer.isView(e)?$a(e):e instanceof Map?m2(e):e instanceof Set?h2(e):Pg(e));return Rg.set(e,t),t}}default:throw new Error(`BUG: unhandled typeof ${typeof e} - please report an issue at https://github.com/Effect-TS/effect/issues`)}},Ad=e=>(kg.has(e)||kg.set(e,On(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))),kg.get(e)),it=l(2,(e,t)=>e*53^t),Ts=e=>e&3221225471|e>>>1&1073741824,p2=e=>M(e,be),On=e=>{if(e!==e)return Ue(\"NaN\");if(e===1/0)return Ue(\"Infinity\");if(e===-1/0)return Ue(\"-Infinity\");let t=e|0;for(t!==e&&(t^=e*4294967295);e>4294967295;)t^=e/=4294967295;return Ts(t)},Ue=e=>{let t=5381,n=e.length;for(;n;)t=t*33^e.charCodeAt(--n);return Ts(t)},Mg=(e,t)=>{let n=12289;for(let r of t)n^=it(H(r),H(e[r]));return Ts(n)},Pg=e=>Mg(e,Cu(e)),Fg=(e,t)=>n=>{let r=e;for(let o of n)r^=t(o);return Ts(r)},$a=Fg(6151,H),m2=Fg(Ue(\"Map\"),([e,t])=>it(H(e),H(t))),h2=Fg(Ue(\"Set\"),H),kg=new WeakMap,Rg=new WeakMap,_g=new WeakSet;function g2(e,t){if(_g.has(e))return Ue(\"[Circular]\");_g.add(e);let n=t();return _g.delete(e),n}var Se=\"~effect/interfaces/Equal\";function B(){return arguments.length===1?e=>pi(e,arguments[0]):pi(arguments[0],arguments[1])}function pi(e,t){if(e===t)return!0;if(e==null||t==null)return!1;let n=typeof e;return n!==typeof t?!1:n===\"number\"&&e!==e&&t!==t?!0:n!==\"object\"&&n!==\"function\"||La.has(e)||La.has(t)?!1:b2(e,t,y2)}function x2(e,t,n){let r=Ug.has(e),o=Ng.has(t);if(r&&o)return!0;if(r||o)return!1;Ug.add(e),Ng.add(t);let s=n();return Ug.delete(e),Ng.delete(t),s}var Ug=new WeakSet,Ng=new WeakSet;function y2(e,t){if(H(e)!==H(t))return!1;if(e instanceof Date){if(!(t instanceof Date))return!1;let s=e.getTime(),i=t.getTime();return s===i||Number.isNaN(s)&&Number.isNaN(i)}else if(e instanceof RegExp)return t instanceof RegExp?e.toString()===t.toString():!1;let n=T0(e),r=T0(t);if(n!==r)return!1;let o=n&&r;return typeof e==\"function\"&&!o?!1:x2(e,t,()=>{if(o)return e[Se](t);if(Array.isArray(e))return!Array.isArray(t)||e.length!==t.length?!1:S2(e,t);if(ArrayBuffer.isView(e)){let s=e instanceof DataView;if(!ArrayBuffer.isView(t)||e.byteLength!==t.byteLength||s!==t instanceof DataView)return!1;if(s){let i=t;return w0(new Uint8Array(e.buffer,e.byteOffset,e.byteLength),new Uint8Array(i.buffer,i.byteOffset,i.byteLength))}return w0(e,t)}else{if(e instanceof Map)return!(t instanceof Map)||e.size!==t.size?!1:I2(e,t);if(e instanceof Set)return!(t instanceof Set)||e.size!==t.size?!1:w2(e,t)}return E2(e,t)})}function b2(e,t,n){let r=Cd.get(e);if(!r)r=new WeakMap,Cd.set(e,r);else if(r.has(t))return r.get(t);let o=n(e,t);r.set(t,o);let s=Cd.get(t);return s||(s=new WeakMap,Cd.set(t,s)),s.set(e,o),o}var Cd=new WeakMap;function S2(e,t){for(let n=0;nM(e,Se),Dg=()=>B;var Lg=e=>(La.add(e),e);function or(e,t,n){return{combine:e,initialValue:t,combineAll:n??(r=>{let o=t;for(let s of r)o=e(o,s);return o})}}var Rt=e=>(t,n)=>t===n||e(t,n),T2=(e,t)=>e===t,v0=()=>T2;function A0(e){return Rt((t,n)=>{if(t.length!==n.length)return!1;for(let r=0;re.length>0;var Rd=e=>l(3,(t,n,r)=>e(t,o=>({...o,[n]:r(o)}))),_d=e=>l(2,(t,n)=>e(t,r=>({[n]:r}))),Md=(e,t)=>l(3,(n,r,o)=>t(n,s=>e(o(s),i=>({...s,[r]:i}))));function F(e,t,n){t===\"__proto__\"?Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0}):e[t]=n}function Pd(e,t){for(let n of Reflect.ownKeys(t))Object.prototype.propertyIsEnumerable.call(t,n)&&F(e,n,t[n])}var ye={};uo(ye,{Do:()=>bL,all:()=>aL,andThen:()=>tL,as:()=>H0,asVoid:()=>X2,bind:()=>yL,bindTo:()=>gL,composeK:()=>sL,contains:()=>mL,containsWith:()=>Z0,exists:()=>hL,filter:()=>Ai,filterMap:()=>fL,firstSomeOf:()=>Y2,flatMap:()=>Ti,flatMapNullishOr:()=>nL,flatten:()=>vi,fromIterable:()=>H2,fromNullOr:()=>Yg,fromNullishOr:()=>Bu,fromUndefinedOr:()=>Zg,gen:()=>SL,getFailure:()=>K2,getOrElse:()=>ju,getOrNull:()=>Wd,getOrThrow:()=>Xg,getOrThrowWith:()=>Qg,getOrUndefined:()=>ks,getSuccess:()=>J2,isNone:()=>ae,isOption:()=>Qa,isSome:()=>_e,let:()=>xL,lift2:()=>dL,liftNullishOr:()=>Q2,liftPredicate:()=>pL,liftThrowable:()=>Rs,makeCombinerFailFast:()=>Y0,makeEquivalence:()=>ex,makeOrder:()=>lL,makeReducer:()=>EL,makeReducerFailFast:()=>IL,map:()=>Bt,match:()=>fr,none:()=>R,orElse:()=>G2,orElseResult:()=>Z2,orElseSome:()=>V2,partitionMap:()=>uL,product:()=>K0,productMany:()=>iL,reduceCompact:()=>cL,some:()=>k,tap:()=>J0,toArray:()=>V0,toRefinement:()=>W2,void:()=>eL,zipLeft:()=>oL,zipRight:()=>rL,zipWith:()=>G0});function Fd(e){return{combine:e}}var Ru=Symbol.for(\"~effect/Redactable\"),A2=e=>M(e,Ru);function Ba(e){return A2(e)?Bg(e):e}function Bg(e){return e[Ru](globalThis[qa]?.context??C2)}var qa=\"~effect/Fiber/currentFiber\",O0=new Map,C2={\"~effect/Context\":{},base:O0,depth:0,mapUnsafe:O0,pipe(){return K(this,arguments)}};function N(e,t){let n=t?.space??0,r=new WeakSet,o=n?typeof n==\"number\"?\" \".repeat(n):n:\"\",s=u=>o.repeat(u),i=(u,f)=>{let d=u?.constructor;return d&&d!==Object.prototype.constructor&&d.name?`${d.name}(${f})`:f},a=u=>{try{return Reflect.ownKeys(u)}catch{return[\"[ownKeys threw]\"]}};function c(u,f=0){if(typeof u==\"string\")return JSON.stringify(u);if(typeof u==\"number\"||u==null||typeof u==\"boolean\"||typeof u==\"symbol\")return String(u);if(typeof u==\"bigint\")return String(u)+\"n\";if(typeof u==\"object\"||typeof u==\"function\"){if(r.has(u))return k2;r.add(u);let d;if(Ru in u)d=c(Bg(u),f);else if(Array.isArray(u))d=!o||u.length<=1?`[${u.map(p=>c(p,f)).join(\",\")}]`:`[\n${s(f+1)}${u.map(p=>c(p,f+1)).join(`,\n`+s(f+1))}\n${s(f)}]`;else if(u instanceof Date)d=qg(u);else if(!t?.ignoreToString&&M(u,\"toString\")&&typeof u.toString==\"function\"&&u.toString!==Object.prototype.toString&&u.toString!==Array.prototype.toString){let p=R2(u);d=u instanceof Error&&u.cause?`${p} (cause: ${c(u.cause,f)})`:p}else if(Symbol.iterator in u)d=`${u.constructor.name}(${c(Array.from(u),f)})`;else{let p=a(u);if(!o||p.length<=1){let m=`{${p.map(g=>`${Bo(g)}:${c(u[g],f)}`).join(\",\")}}`;d=i(u,m)}else{let m=`{\n${p.map(g=>`${s(f+1)}${Bo(g)}: ${c(u[g],f+1)}`).join(`,\n`)}\n${s(f)}}`;d=i(u,m)}}return r.delete(u),d}return String(u)}return c(e,0)}var k2=\"[Circular]\";function Bo(e){return typeof e==\"string\"?JSON.stringify(e):String(e)}function Ud(e){return e.map(t=>`[${Bo(t)}]`).join(\"\")}function qg(e){try{return e.toISOString()}catch{return\"Invalid Date\"}}function R2(e){try{let t=e.toString();return typeof t==\"string\"?t:String(t)}catch{return\"[toString threw]\"}}function mi(e,t){let n=[];return JSON.stringify(e,function(r,o){let s=Object.getOwnPropertyDescriptor(this,r)?.value,i=M(s,Ru)?Ba(s):Ba(o);if(typeof i==\"bigint\")return N(i);if(typeof i!=\"object\"||i===null)return i;for(;n.length>0&&n[n.length-1]!==this;)n.pop();if(!n.includes(i))return n.push(i),i},t?.space)??\"null\"}var Qe=Symbol.for(\"nodejs.util.inspect.custom\"),ht=e=>{try{return e=Ba(e),M(e,\"toJSON\")&&un(e.toJSON)&&e.toJSON.length===0?e.toJSON():Array.isArray(e)?e.map(ht):e}catch{return\"[toJSON threw]\"}},R0=(e,t=2)=>{if(typeof e==\"string\")return e;try{return typeof e==\"object\"?mi(e,{space:t}):N(e,{space:t})}catch{return String(e)}},FQ={toJSON(){return ht(this)},[Qe](){return this.toJSON()},toString(){return N(this.toJSON())}},k0=class{[Qe](){return this.toJSON()}toString(){return N(this.toJSON())}};var vs=class e{called=!1;self;constructor(t){this.self=t}next(t){return this.called?{value:t,done:!0}:(this.called=!0,{value:this.self,done:!1})}[Symbol.iterator](){return new e(this.self)}},M2=()=>{let e=\"~effect/Utils/internal\",t={[e]:o=>o()},n={[e]:o=>o()};return t[e](()=>new Error().stack)?.includes(e)===!0?t[e]:n[e]},we=M2();var po=\"~effect/Effect\",hi=\"~effect/Exit\",F2={_A:_,_E:_,_R:_},P0=`${po}/identifier`,Z=`${po}/args`,at=`${po}/evaluate`,fn=`${po}/successCont`,sr=`${po}/failureCont`,As=`${po}/ensureCont`,yi=Symbol.for(\"effect/Effect/Yield\"),ln={pipe(){return K(this,arguments)},toJSON(){return{...this}},toString(){return N(this.toJSON(),{ignoreToString:!0,space:2})},[Qe](){return this.toJSON()}},F0={[be](){return Mg(this,Object.keys(this))},[Se](e){let t=Object.keys(this),n=Object.keys(e);if(t.length!==n.length)return!1;for(let r=0;rM(e,po),zg=e=>M(e,hi),za=\"~effect/Cause\",Wa=\"~effect/Cause/Reason\",Ja=e=>M(e,za),U0=e=>M(e,Wa),lo=class{[za];reasons;constructor(t){this[za]=za,this.reasons=t}pipe(){return K(this,arguments)}toJSON(){return{_id:\"Cause\",failures:this.reasons.map(t=>t.toJSON())}}toString(){return`Cause(${N(this.reasons)})`}[Qe](){return this.toJSON()}[Se](t){return Ja(t)&&this.reasons.length===t.reasons.length&&this.reasons.every((n,r)=>B(n,t.reasons[r]))}[be](){return $a(this.reasons)}},M0=new WeakMap,Ha=class{[Wa];annotations;_tag;constructor(t,n,r){if(this[Wa]=Wa,this._tag=t,n!==Pu&&typeof r==\"object\"&&r!==null&&n.size>0){let o=M0.get(r);o&&(n=new Map([...o,...n])),M0.set(r,n)}this.annotations=n}annotate(t,n){if(t.mapUnsafe.size===0)return this;let r=new Map(this.annotations);t.mapUnsafe.forEach((s,i)=>{n?.overwrite!==!0&&r.has(i)||r.set(i,s)});let o=Object.assign(Object.create(Object.getPrototypeOf(this)),this);return o.annotations=r,o}pipe(){return K(this,arguments)}toString(){return N(this)}[Qe](){return this.toString()}},Pu=new Map,gi=class extends Ha{error;constructor(t,n=Pu){super(\"Fail\",n,t),this.error=t}toString(){return`Fail(${N(this.error)})`}toJSON(){return{_tag:\"Fail\",error:this.error}}[Se](t){return Cs(t)&&B(this.error,t.error)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.error))(H(this.annotations)))}},qo=e=>new lo(e),Nd=new lo([]),Ka=e=>new lo([new gi(e)]),_u=class extends Ha{defect;constructor(t,n=Pu){super(\"Die\",n,t),this.defect=t}toString(){return`Die(${N(this.defect)})`}toJSON(){return{_tag:\"Die\",defect:this.defect}}[Se](t){return bi(t)&&B(this.defect,t.defect)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.defect))(H(this.annotations)))}},Wg=e=>new lo([new _u(e)]),Ga=l(e=>Ja(e[0]),(e,t,n)=>t.mapUnsafe.size===0?e:new lo(e.reasons.map(r=>r.annotate(t,n)))),Cs=e=>e._tag===\"Fail\",bi=e=>e._tag===\"Die\",Si=e=>e._tag===\"Interrupt\";function N2(e){return ar(\"Effect.evaluate: Not implemented\")}var zo=e=>({...U2,[P0]:e.op,[at]:e[at]??N2,[fn]:e[fn],[sr]:e[sr],[As]:e[As]}),ir=e=>{let t=zo(e);return function(){let n=Object.create(t);return n[Z]=e.single===!1?arguments:arguments[0],n}},N0=e=>{let t={[hi]:hi,_tag:e.op,get[e.prop](){return this[Z]},...zo(e),toString(){return`${e.op}(${N(this[Z])})`},toJSON(){return{_id:\"Exit\",_tag:e.op,[e.prop]:this[Z]}},[Se](n){return zg(n)&&n._tag===this._tag&&B(this[Z],n[Z])},[be](){return it(Ue(e.op),H(this[Z]))}};return function(n){let r=Object.create(t);return r[Z]=n,r}},Oe=N0({op:\"Success\",prop:\"value\",[at](e){let t=e.getCont(fn);return t?t[fn](this[Z],e,this):e.yieldWith(this)}}),Dd={key:\"effect/Cause/StackTrace\"},Hg={key:\"effect/Cause/InterruptorStackTrace\"},Mt=N0({op:\"Failure\",prop:\"cause\",[at](e){let t=this[Z],n=!1;e.currentStackFrame&&(t=Ga(t,{mapUnsafe:new Map([[Dd.key,e.currentStackFrame]])}),n=!0);let r=e.getCont(sr);for(;e.interruptible&&e._interruptedCause&&r;)r=e.getCont(sr);return r?r[sr](t,e,n?void 0:this):e.yieldWith(n?Mt(t):this)}}),jn=e=>Mt(Ka(e)),ar=e=>Mt(Wg(e)),Y=ir({op:\"WithFiber\",[at](e){return this[Z](e)}}),D2=(function(){class e extends globalThis.Error{}let t=zo({op:\"YieldableError\",[at](){return jn(this)}});return delete t.toString,Object.assign(e.prototype,t),e})(),Fu=(function(){let e=Symbol.for(\"effect/Data/Error/plainArgs\");return class extends D2{constructor(n){super(n?.message,n?.cause?{cause:n.cause}:void 0),n&&(Pd(this,n),Object.defineProperty(this,e,{value:n,enumerable:!1}))}toJSON(){return{...this[e],...this}}}})(),Wo=e=>{class t extends Fu{_tag=e}return t.prototype.name=e,t},Mu=\"~effect/Cause/NoSuchElementError\",Ld=e=>M(e,Mu),_r=class extends Wo(\"NoSuchElementError\"){[Mu]=Mu;constructor(t){super({message:t})}},xi=\"~effect/Cause/Done\",Uu=e=>M(e,xi),D0={[xi]:xi,_tag:\"Done\",value:void 0},Ei=e=>e===void 0?D0:{[xi]:xi,_tag:\"Done\",value:e},L2=jn(D0),L0=e=>e===void 0?L2:jn(Ei(e));var $0=\"~effect/data/Option\",j0={[$0]:{_A:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},$2=Object.defineProperty(Object.assign(Object.create(j0),{_tag:\"Some\",_op:\"Some\",[Se](e){return $d(e)&&Jg(e)&&B(this.value,e.value)},[be](){return it(H(this._tag))(H(this.value))},toString(){return`some(${N(this.value)})`},toJSON(){return{_id:\"Option\",_tag:this._tag,value:ht(this.value)}}}),\"valueOrUndefined\",{get(){return this.value}}),j2=H(\"None\"),B2=Object.assign(Object.create(j0),{_tag:\"None\",_op:\"None\",valueOrUndefined:void 0,[Se](e){return $d(e)&&jd(e)},[be](){return j2},toString(){return\"none()\"},toJSON(){return{_id:\"Option\",_tag:this._tag}}}),$d=e=>M(e,$0),jd=e=>e._tag===\"None\",Jg=e=>e._tag===\"Some\",Nu=Object.create(B2),Du=e=>{let t=Object.create($2);return t.value=e,t};var q0=\"~effect/data/Result\",z0={[q0]:{_A:e=>e,_E:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},q2=Object.assign(Object.create(z0),{_tag:\"Success\",_op:\"Success\",[Se](e){return Bd(e)&&Za(e)&&B(this.success,e.success)},[be](){return it(H(this._tag))(H(this.success))},toString(){return`success(${N(this.success)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,value:ht(this.success)}}}),z2=Object.assign(Object.create(z0),{_tag:\"Failure\",_op:\"Failure\",[Se](e){return Bd(e)&&Va(e)&&B(this.failure,e.failure)},[be](){return it(H(this._tag))(H(this.failure))},toString(){return`failure(${N(this.failure)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,failure:ht(this.failure)}}}),Bd=e=>M(e,q0),Va=e=>e._tag===\"Failure\",Za=e=>e._tag===\"Success\",qd=e=>{let t=Object.create(z2);return t.failure=e,t},zd=e=>{let t=Object.create(q2);return t.success=e,t},Kg=e=>Za(e)?Nu:Du(e.failure),Gg=e=>Va(e)?Nu:Du(e.success);function Ho(e){return(t,n)=>t===n?0:e(t,n)}var Bn=Ho((e,t)=>globalThis.Number.isNaN(e)&&globalThis.Number.isNaN(t)?0:globalThis.Number.isNaN(e)?-1:globalThis.Number.isNaN(t)?1:eeHo((n,r)=>e(t(n),t(r)))),Ii=Vg(Bn,e=>e.getTime());var Ya=e=>l(2,(t,n)=>e(t,n)===-1),Os=e=>l(2,(t,n)=>e(t,n)===1),Lu=e=>l(2,(t,n)=>e(t,n)!==1),$u=e=>l(2,(t,n)=>e(t,n)!==-1);var R=()=>Nu,k=Du,Qa=$d,ae=jd,_e=Jg,fr=l(2,(e,{onNone:t,onSome:n})=>ae(e)?t():n(e.value)),W2=e=>t=>_e(e(t)),H2=e=>{for(let t of e)return k(t);return R()},J2=Gg,K2=Kg,ju=l(2,(e,t)=>ae(e)?t():e.value),G2=l(2,(e,t)=>ae(e)?t():e),V2=l(2,(e,t)=>ae(e)?k(t()):e),Z2=l(2,(e,t)=>ae(e)?Bt(t(),zd):Bt(e,qd)),Y2=e=>{let t=R();for(t of e)if(_e(t))return t;return t},Bu=e=>e==null?R():k(e),Zg=e=>e===void 0?R():k(e),Yg=e=>e===null?R():k(e),Q2=e=>(...t)=>Bu(e(...t)),Wd=ju(Ag),ks=ju(fo),Rs=e=>(...t)=>{try{return k(e(...t))}catch{return R()}},Qg=l(2,(e,t)=>{if(_e(e))return e.value;throw t()}),Xg=Qg(()=>new Error(\"getOrThrow called on a None\")),Bt=l(2,(e,t)=>ae(e)?R():k(t(e.value))),H0=l(2,(e,t)=>Bt(e,()=>t)),X2=H0(void 0),eL=k(void 0);var Ti=l(2,(e,t)=>ae(e)?R():t(e.value)),tL=l(2,(e,t)=>Ti(e,n=>{let r=un(t)?t(n):t;return Qa(r)?r:k(r)})),nL=l(2,(e,t)=>ae(e)?R():Bu(t(e.value))),vi=Ti(_),rL=l(2,(e,t)=>Ti(e,()=>t)),oL=l(2,(e,t)=>J0(e,()=>t)),sL=l(2,(e,t)=>n=>Ti(e(n),t)),J0=l(2,(e,t)=>Ti(e,n=>Bt(t(n),()=>n))),K0=(e,t)=>_e(e)&&_e(t)?k([e.value,t.value]):R(),iL=(e,t)=>{if(ae(e))return R();let n=[e.value];for(let r of t){if(ae(r))return R();n.push(r.value)}return k(n)},aL=e=>{if(Symbol.iterator in e){let n=[];for(let r of e){if(ae(r))return R();n.push(r.value)}return k(n)}let t={};for(let n of Object.keys(e)){let r=e[n];if(ae(r))return R();F(t,n,r.value)}return k(t)},G0=l(3,(e,t,n)=>Bt(K0(e,t),([r,o])=>n(r,o))),cL=l(3,(e,t,n)=>{let r=t;for(let o of e)_e(o)&&(r=n(r,o.value));return r}),V0=e=>ae(e)?[]:[e.value],uL=l(2,(e,t)=>{if(ae(e))return[R(),R()];let n=t(e.value);return Va(n)?[k(n.failure),R()]:[R(),k(n.success)]}),fL=l(2,(e,t)=>{if(ae(e))return R();let n=t(e.value);return Za(n)?k(n.success):R()}),Ai=l(2,(e,t)=>ae(e)?R():t(e.value)?k(e.value):R()),ex=e=>Rt((t,n)=>ae(t)?ae(n):ae(n)?!1:e(t.value,n.value)),lL=e=>Ho((t,n)=>_e(t)?_e(n)?e(t.value,n.value):1:-1),dL=e=>l(2,(t,n)=>G0(t,n,e)),pL=l(2,(e,t)=>t(e)?k(e):R()),Z0=e=>l(2,(t,n)=>ae(t)?!1:e(t.value,n)),mL=Z0(Dg()),hL=l(2,(e,t)=>ae(e)?!1:t(e.value)),gL=_d(Bt),xL=Rd(Bt);var yL=Md(Bt,Ti),bL=k({}),SL=(...e)=>{let n=(e.length===1?e[0]:e[1].bind(e[0]))(),r=n.next();for(;!r.done;){let o=r.value;if(ae(o))return o;r=n.next(o.value)}return k(r.value)};function EL(e){return or((t,n)=>ae(t)?n:ae(n)?t:k(e.combine(t.value,n.value)),R())}function Y0(e){return Fd((t,n)=>ae(t)||ae(n)?R():k(e.combine(t.value,n.value)))}function IL(e){let t=Y0(e).combine,n=k(e.initialValue);return or(t,n,r=>{let o=n;for(let s of r)if(o=t(o,s),ae(o))return o;return o})}var se=zd,re=qd;var Q0=e=>{if(un(e))try{return se(e())}catch(t){return re(t)}else try{return se(e.try())}catch(t){return re(e.catch(t))}};var X0=Bd,ie=Va,Tt=Za;var eT=(e,t)=>Rt((n,r)=>ie(n)?ie(r)&&t(n.failure,r.failure):Tt(r)&&e(n.success,r.success));var Hd=l(2,(e,t)=>ie(e)?re(t(e.failure)):e),Ci=l(2,(e,t)=>Tt(e)?se(t(e.success)):e),lr=l(2,(e,{onFailure:t,onSuccess:n})=>ie(e)?t(e.failure):n(e.success));var qu=l(2,(e,t)=>ie(e)?t(e.failure):e.success);var Jd=l(2,(e,t)=>ie(e)?re(e.failure):t(e.success));var wL=(e,t)=>{let n=t?.length!==void 0?Math.max(1,Math.floor(t.length)):1/0;return{[Symbol.iterator](){let r=0;return{next(){return rvL(wL(()=>e,{length:t}))),tT=e=>TL(e,1/0);var nT=e=>{let n=e[Symbol.iterator]().next();if(n.done)throw new Error(\"headUnsafe: empty iterable\");return n.value};var vL=e=>({[Symbol.iterator](){let t=e[Symbol.iterator](),n;function r(){for(;;){if(n===void 0){let s=t.next();if(s.done)return s;n=s.value[Symbol.iterator]()}let o=n.next();if(!o.done)return o;n=void 0}}return{next:r}}});var rT=l(2,(e,t)=>({[Symbol.iterator](){let n=e[Symbol.iterator](),r=0;return{next(){let o=n.next();for(;!o.done;){if(t(o.value,r++))return{done:!1,value:o.value};o=n.next()}return{done:!0,value:void 0}}}}}));var zu=l(2,(e,t)=>{let n={...e};for(let r of AL(e))F(n,r,t(e[r],r));return n});var AL=e=>Object.keys(e);var Wu=globalThis.Array;var oT=l(2,(e,t)=>{let n=Math.max(1,Math.floor(e)),r=new Wu(n);for(let o=0;oe<=t?oT(t-e+1,n=>e+n):[e];var Be=e=>Wu.isArray(e)?e:Wu.from(e),sT=e=>Wu.isArray(e)?e:[e];var rx=l(2,(e,t)=>[...e,t]),ox=l(2,(e,t)=>Be(e).concat(Be(t)));var iT=Wu.isArray;var qt=$g,Me=$g;function CL(e,t){return!Number.isFinite(e)||e<0||e>=t.length}var aT=l(2,(e,t)=>{let n=Math.floor(t);if(CL(n,e))throw new Error(`Index out of bounds: ${n}`);return e[n]});var Gd=e=>e[e.length-1];var cT=l(2,(e,t)=>{let n=0,r=[];for(let o of e){if(!t(o,n))break;r.push(o),n++}return r});var OL=(e,t)=>{let n=H(t),r=e.get(n);if(r===void 0)return e.set(n,[t]),!0;for(let o of r)if(B(o,t))return!1;return r.push(t),!0};var sx=l(2,(e,t)=>{let n=Be(e),r=Be(t);return Me(n)?Me(r)?Vd(ox(n,r)):n:r});var gt=()=>[],Ee=e=>[e],Mr=l(2,(e,t)=>e.map(t));var ix=e=>{let t=[];for(let n of e)_e(n)&&t.push(n.value);return t};var ax=l(2,(e,t)=>{let n=Be(e),r=[];for(let o=0;o{let n=[],r=[],o=0;for(let s of e){let i=t(s,o++);Tt(i)?r.push(i.success):n.push(i.failure)}return[n,r]});var Vd=e=>{let t=Be(e);if(t.length<2)return[...t];let n=new Map,r=[];for(let o of t)OL(n,o)&&r.push(o);return r};var kL=or((e,t)=>e.concat(t),[]);function uT(){return kL}var fT=/^[+-]?\\d+$/,ux=\"~effect/BigDecimal\",FL={[ux]:ux,[be](){let e=fx(this);return it(H(e.value),On(e.scale))},[Se](e){return Yd(e)&&jL(this,e)},toString(){return`BigDecimal(${mo(this)})`},toJSON(){return{_id:\"BigDecimal\",value:String(this.value),scale:this.scale}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},Yd=e=>M(e,ux),Rn=(e,t)=>{let n=Object.create(FL);return n.value=e,n.scale=t,n},dT=(e,t)=>{if(e!==qn&&e%Zd===qn)throw new RangeError(\"Value must be normalized\");let n=Rn(e,t);return n.normalized=n,n},qn=BigInt(0),UL=BigInt(1),NL=BigInt(-1);var Zd=BigInt(10),pT=dT(qn,0);var fx=e=>{if(e.normalized===void 0)if(e.value===qn)e.normalized=pT;else{let t=`${e.value}`,n=0;for(let s=t.length-1;s>=0&&t[s]===\"0\";s--)n++;n===0&&(e.normalized=e);let r=BigInt(t.substring(0,t.length-n)),o=e.scale-n;e.normalized=dT(r,o)}return e.normalized},_s=l(2,(e,t)=>t>e.scale?Rn(e.value*Zd**BigInt(t-e.scale),t):tt.value===qn?e:e.value===qn?t:e.scale>t.scale?Rn(_s(t,e.scale).value+e.value,e.scale):e.scale{let n=Bn(lT(e),lT(t));return n!==0?n:e.scale>t.scale?ur(e.value,_s(t,e.scale).value):e.scalee.value===qn?0:e.valuee.valuee.scale>t.scale?_s(t,e.scale).value===e.value:e.scalelx(e,t));var hT=e=>{if(e===\"\")return k(pT);let t,n,r=e.search(/[eE]/);if(r!==-1){let c=e.slice(r+1);if(t=e.slice(0,r),n=Number(c),t===\"\"||!Number.isSafeInteger(n)||!fT.test(c))return R()}else t=e,n=0;let o,s,i=t.search(/\\./);if(i!==-1){let c=t.slice(0,i),u=t.slice(i+1);o=`${c}${u}`,s=u.length}else o=t,s=0;if(!fT.test(o))return R();let a=s-n;return Number.isSafeInteger(a)?k(Rn(BigInt(o),a)):R()};var mo=e=>{let t=fx(e);if(Math.abs(t.scale)>=16)return BL(t);let n=t.value=r.length)o=\"0\",s=\"0\".repeat(t.scale-r.length)+r;else{let a=r.length-t.scale;if(a>r.length){let c=a-r.length;o=`${r}${\"0\".repeat(c)}`,s=\"\"}else s=r.slice(a),o=r.slice(0,a)}let i=s===\"\"?o:`${o}.${s}`;return n?`-${i}`:i},BL=e=>{if(qL(e))return\"0e+0\";let t=fx(e),n=`${$L(t).value}`,r=n.slice(0,1),o=n.slice(1),s=`${gT(t)?\"-\":\"\"}${r}`;o!==\"\"&&(s+=`.${o}`);let i=o.length-t.scale;return`${s}e${i>=0?\"+\":\"\"}${i}`};var qL=e=>e.value===qn,gT=e=>e.valuee.value>qn,dx=e=>Yd(e[0]);var xT=l(dx,(e,t=0)=>e.scale<=t?e:Rn(e.value/Zd**BigInt(e.scale-t),t)),px=l(dx,(e,t=0)=>{let n=xT(e,t);return zL(e)&&DL(n,e)?mT(n,Rn(UL,t)):n});var mx=l(dx,(e,t=0)=>{let n=xT(e,t);return gT(e)&&LL(n,e)?mT(n,Rn(NL,t)):n});var l9=globalThis.Boolean;var yT=or((e,t)=>e||t,!1);var Wt={};uo(Wt,{AsyncFiberError:()=>Lj,AsyncFiberErrorTypeId:()=>Nj,Done:()=>ab,DoneTypeId:()=>Oj,ExceededCapacityError:()=>cb,ExceededCapacityErrorTypeId:()=>Uj,IllegalArgumentError:()=>Tf,IllegalArgumentErrorTypeId:()=>Mj,InterruptorStackTrace:()=>Qy,NoSuchElementError:()=>Cj,NoSuchElementErrorTypeId:()=>Aj,ReasonTypeId:()=>uj,StackTrace:()=>Yy,TimeoutError:()=>_j,TimeoutErrorTypeId:()=>kj,TypeId:()=>cj,UnknownError:()=>Bj,UnknownErrorTypeId:()=>$j,annotate:()=>qj,annotations:()=>Wj,combine:()=>mj,die:()=>nb,done:()=>G,empty:()=>tb,fail:()=>dj,filterInterruptors:()=>wj,findDefect:()=>bj,findDie:()=>yj,findError:()=>sb,findErrorOption:()=>gj,findFail:()=>hj,findInterrupt:()=>Ej,fromReasons:()=>ji,hasDies:()=>xj,hasFails:()=>ob,hasInterrupts:()=>Sj,hasInterruptsOnly:()=>rb,interrupt:()=>pj,interruptors:()=>Ij,isAsyncFiberError:()=>Dj,isCause:()=>Xy,isDieReason:()=>fj,isDone:()=>fc,isExceededCapacityError:()=>Fj,isFailReason:()=>Sf,isIllegalArgumentError:()=>Pj,isInterruptReason:()=>lj,isNoSuchElementError:()=>vj,isReason:()=>eb,isTimeoutError:()=>Rj,isUnknownError:()=>jj,makeDieReason:()=>If,makeFailReason:()=>Ef,makeInterruptReason:()=>wf,map:()=>ns,pretty:()=>ib,prettyErrors:()=>Tj,reasonAnnotations:()=>zj,squash:()=>zp});var Qd=e=>zo({op:e.label,[at]:e.evaluate});var ST=\"~effect/Context/Service\",Vt=function(){function e(){}let t=e;Object.setPrototypeOf(t,WL);let n=(r,o)=>(t.key=r,o?.defaultValue&&(t[yx]=yx,t.defaultValue=o.defaultValue),o?.make&&(t.make=o.make),o?.fiberCached&&ET.add(r),t);return arguments.length>0?n(arguments[0],arguments[1]):n},WL={[ST]:ST,...Qd({label:\"Service\",evaluate(e){return Oe(Xe(e.context,this))}}),toJSON(){return{_id:\"Service\",key:this.key}},of(e){return e},context(e){return Oi(this,e)},use(e){return Y(t=>e(Xe(t.context,this)))},useSync(e){return Y(t=>Oe(e(Xe(t.context,this))))}},ET=new Set,yx=\"~effect/Context/Reference\",IT=\"~effect/Context\",HL=8,JL=8,bx=(e,t,n,r)=>{let o=Object.create(GL);return o.cacheRoot=e??o,o.base=t,o.overlay=n,o.depth=r,o._flat=void 0,o.baseHits=0,o},wT=(e,t)=>{t&&(wT(e,t.parent),e.set(t.key,t.value))},TT=e=>{if(e._flat)return e._flat;if(!e.overlay)return e._flat=e.base;let t=new Map(e.base);return wT(t,e.overlay),e._flat=t},KL=(e,t)=>{let n=new Map(e.mapUnsafe);return t(n),ho(n)},Xd=Symbol(),Sx=(e,t)=>{let n=e;for(let o=n.overlay;o;o=o.parent)if(o.key===t)return o.value;let r=n.base.get(t);return r===void 0&&!n.base.has(t)?Xd:(n.overlay&&++n.baseHits>=JL&&(n.base=TT(n),n.overlay=void 0,n.depth=0),r)},ho=e=>bx(void 0,e,void 0,0),GL={get mapUnsafe(){return TT(this)},...ln,[IT]:{_Services:e=>e},toJSON(){return{_id:\"Context\",services:Array.from(this.mapUnsafe).map(([e,t])=>({key:e,value:t}))}},[Se](e){if(!Hu(e))return!1;let t=this.mapUnsafe,n=e.mapUnsafe;if(t.size!==n.size)return!1;for(let[r,o]of t)if(!n.has(r)||!B(o,n.get(r)))return!1;return!0},[be](){return On(this.mapUnsafe.size)}},vT=(e,t)=>e.cacheRoot===t.cacheRoot,Hu=e=>M(e,IT);var AT=e=>!!e[yx],pn=()=>VL,VL=ho(new Map),Oi=(e,t)=>ho(new Map([[e.key,t]])),Pt=l(3,(e,t,n)=>ZL(e,t.key,n)),ZL=(e,t,n)=>{let r=e,o=ET.has(t)?void 0:r.cacheRoot;if(r.depth>=HL){let s=new Map(r.mapUnsafe);return s.set(t,n),bx(o,s,void 0,0)}return bx(o,r.base,{key:t,value:n,parent:r.overlay},r.depth+1)};var ep=l(2,(e,t)=>Ju(e,t.key)),Ju=(e,t)=>{let n=Sx(e,t);return n===Xd?void 0:n},Pr=l(2,(e,t)=>{let n=Sx(e,t.key);if(n===Xd){if(AT(t))return CT(t);throw YL(t)}return n}),Xe=Pr,xx=\"~effect/Context/defaultValue\",CT=e=>xx in e?e[xx]:e[xx]=e.defaultValue(),YL=e=>{let t=new Error(`Service not found${e.key?`: ${String(e.key)}`:\"\"}`);if(t.stack){let n=t.stack.split(`\n`);n.splice(1,3),t.stack=n.join(`\n`)}return t},Ex=l(2,(e,t)=>{let n=Sx(e,t.key);return n!==Xd?k(n):AT(t)?k(CT(t)):R()}),tp=l(2,(e,t)=>e.mapUnsafe.size===0?t:t.mapUnsafe.size===0?e:KL(e,n=>t.mapUnsafe.forEach((r,o)=>n.set(o,r)))),OT=(...e)=>{let t=new Map;for(let n=0;n{t.set(o,r)});return ho(t)};var Ae=Vt;var np=\"~effect/time/Duration\",wx=BigInt(0),MT=BigInt(1),QL=BigInt(2),XL=BigInt(10);var e$=BigInt(1e3);var rp=e=>BigInt(e<0?Math.ceil(e-.5):Math.floor(e+.5)),PT=e=>rp(e*1e6),kT=(e,t)=>{let n=e.indexOf(\".\");if(n===-1)return BigInt(e)*t;let r=e[0]===\"-\",o=e.slice(n+1),s=XL**BigInt(o.length),i=(BigInt(e.slice(r?1:0,n))*s+BigInt(o))*t,a=i/s+(i%s*QL>=s?MT:wx);return r?-a:a};var t$=/^(-?\\d+(?:\\.\\d+)?)\\s+(nanos?|micros?|millis?|seconds?|minutes?|hours?|days?|weeks?)$/,mt=e=>{switch(typeof e){case\"number\":return go(e);case\"bigint\":return dr(e);case\"string\":{if(e===\"Infinity\")return Fr;if(e===\"-Infinity\")return ki;let t=t$.exec(e);if(!t)break;let[n,r,o]=t;if(o===\"nano\"||o===\"nanos\")return dr(kT(r,MT));if(o===\"micro\"||o===\"micros\")return dr(kT(r,e$));let s=Number(r);switch(o){case\"milli\":case\"millis\":return go(s);case\"second\":case\"seconds\":return s$(s);case\"minute\":case\"minutes\":return i$(s);case\"hour\":case\"hours\":return a$(s);case\"day\":case\"days\":return c$(s);case\"week\":case\"weeks\":return u$(s)}break}case\"object\":{if(e===null)break;if(np in e)return e;if(Array.isArray(e))return e.length!==2||!e.every(Ou)?RT(e):Number.isNaN(e[0])||Number.isNaN(e[1])?Ms:e[0]===-1/0||e[1]===-1/0?ki:e[0]===1/0||e[1]===1/0?Fr:mn(rp(e[0]*1e9+e[1]));let t=e,n=0;return t.weeks&&(n+=t.weeks*6048e5),t.days&&(n+=t.days*864e5),t.hours&&(n+=t.hours*36e5),t.minutes&&(n+=t.minutes*6e4),t.seconds&&(n+=t.seconds*1e3),t.milliseconds&&(n+=t.milliseconds),!t.microseconds&&!t.nanoseconds?mn(n):mn(rp(n*1e6+(t.microseconds??0)*1e3+(t.nanoseconds??0)))}}return RT(e)},RT=e=>{throw new Error(`Invalid Input: ${e}`)},FT=Rs(mt),_T={_tag:\"Millis\",millis:0},n$={_tag:\"Infinity\"},r$={_tag:\"NegativeInfinity\"},o$={[np]:np,[be](){switch(this.value._tag){case\"Millis\":{let e=this.value.millis*1e6;return Number.isFinite(e)?H(rp(e)):On(this.value.millis)}case\"Nanos\":return H(this.value.nanos);default:return Pg(this.value)}},[Se](e){return Tx(e)&&l$(this,e)},toString(){switch(this.value._tag){case\"Infinity\":return\"Infinity\";case\"NegativeInfinity\":return\"-Infinity\";case\"Nanos\":return`${this.value.nanos} nanos`;case\"Millis\":return`${this.value.millis} millis`}},toJSON(){switch(this.value._tag){case\"Millis\":return{_id:\"Duration\",_tag:\"Millis\",millis:this.value.millis};case\"Nanos\":return{_id:\"Duration\",_tag:\"Nanos\",nanos:String(this.value.nanos)};case\"Infinity\":return{_id:\"Duration\",_tag:\"Infinity\"};case\"NegativeInfinity\":return{_id:\"Duration\",_tag:\"NegativeInfinity\"}}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},mn=e=>{let t=Object.create(o$);return typeof e==\"number\"?isNaN(e)||e===0||Object.is(e,-0)?t.value=_T:Number.isFinite(e)?Number.isInteger(e)?t.value={_tag:\"Millis\",millis:e}:t.value={_tag:\"Nanos\",nanos:PT(e)}:t.value=e>0?n$:r$:e===wx?t.value=_T:t.value={_tag:\"Nanos\",nanos:e},t},Tx=e=>M(e,np),Xa=e=>e.value._tag!==\"Infinity\"&&e.value._tag!==\"NegativeInfinity\",Ku=e=>{switch(e.value._tag){case\"Millis\":return e.value.millis===0;case\"Nanos\":return e.value.nanos===wx;case\"Infinity\":case\"NegativeInfinity\":return!1}};var Ms=mn(0),Fr=mn(1/0),ki=mn(-1/0),dr=e=>mn(e);var go=e=>mn(e),s$=e=>mn(e*1e3),i$=e=>mn(e*6e4),a$=e=>mn(e*36e5),c$=e=>mn(e*864e5),u$=e=>mn(e*6048e5),_n=e=>f$(mt(e),{onMillis:_,onNanos:t=>Number(t)/1e6,onInfinity:()=>1/0,onNegativeInfinity:()=>-1/0});var Ix=e=>{let t=mt(e);switch(t.value._tag){case\"Infinity\":case\"NegativeInfinity\":throw new Error(\"Cannot convert infinite duration to nanos\");case\"Nanos\":return t.value.nanos;case\"Millis\":return PT(t.value.millis)}},UT=Rs(Ix);var f$=l(2,(e,t)=>{switch(e.value._tag){case\"Millis\":return t.onMillis(e.value.millis);case\"Nanos\":return t.onNanos(e.value.nanos);case\"Infinity\":return t.onInfinity();case\"NegativeInfinity\":return(t.onNegativeInfinity??t.onInfinity)()}}),NT=l(3,(e,t,n)=>e.value._tag===\"Infinity\"||e.value._tag===\"NegativeInfinity\"||t.value._tag===\"Infinity\"||t.value._tag===\"NegativeInfinity\"?n.onInfinity(e,t):e.value._tag===\"Millis\"?t.value._tag===\"Millis\"?n.onMillis(e.value.millis,t.value.millis):n.onNanos(Ix(e),t.value.nanos):n.onNanos(e.value.nanos,Ix(t)));var vx=(e,t)=>NT(e,t,{onMillis:(n,r)=>n===r,onNanos:(n,r)=>n===r,onInfinity:(n,r)=>n.value._tag===r.value._tag});var DT=l(2,(e,t)=>NT(e,t,{onMillis:(n,r)=>mn(n-r),onNanos:(n,r)=>mn(n-r),onInfinity:(n,r)=>{let o=n.value._tag,s=r.value._tag;return o===\"Infinity\"?s===\"Infinity\"?Ms:Fr:o===\"NegativeInfinity\"?s===\"NegativeInfinity\"?Ms:ki:s===\"Infinity\"?ki:Fr}}));var l$=l(2,(e,t)=>vx(e,t));var Ax=l(2,(e,t)=>n=>{let r=e(n);if(ie(r))return re(n);let o=t(r.success);return ie(o)?re(n):o}),LT=e=>t=>{let n=e(t);return ie(n)?R():k(n.success)};var Gu=Ae(\"effect/Scheduler\",{fiberCached:!0,defaultValue:()=>new Ri}),jT=\"setImmediate\"in globalThis?e=>{let t=globalThis.setImmediate(e);return()=>globalThis.clearImmediate(t)}:e=>{let t=setTimeout(e,0);return()=>clearTimeout(t)},d$=e=>{let t=!1;return Promise.resolve().then(()=>{t||e()}),()=>{t=!0}},Cx=class{buckets=[];scheduleTask(t,n){let r=this.buckets,o=r.length,s,i=0;for(;in);i++)s=r[i];s&&s[0]===n?s[1].push(t):i===o?r.push([n,[t]]):r.splice(i,0,[n,[t]])}drain(){let t=this.buckets;return this.buckets=[],t}},Ri=class{executionMode;setImmediate;constructor(t=\"async\",n){this.executionMode=t,this.setImmediate=n??(t===\"sync\"?d$:jT)}shouldYield(t){return t.currentOpCount>=t.maxOpsBeforeYield}makeDispatcher(){return new Ox(this.setImmediate)}},Ox=class{tasks=new Cx;running=void 0;setImmediate;constructor(t=jT){this.setImmediate=t}scheduleTask(t,n){this.tasks.scheduleTask(t,n),this.running===void 0&&(this.running=this.setImmediate(this.afterScheduled))}afterScheduled=()=>{this.running=void 0,this.runTasks()};runTasks(){let t=this.tasks.drain();for(let n=0;n0;)this.running!==void 0&&(this.running(),this.running=void 0),this.runTasks()}},BT=Ae(\"effect/Scheduler/MaxOpsBeforeYield\",{fiberCached:!0,defaultValue:()=>2048}),qT=Ae(\"effect/Scheduler/PreventSchedulerYield\",{fiberCached:!0,defaultValue:()=>!1});var bo={};uo(bo,{Class:()=>op,Error:()=>zT,TaggedClass:()=>p$,TaggedError:()=>yo,taggedEnum:()=>m$});var op=class extends li{constructor(e){super(),e&&Pd(this,e)}},p$=e=>class extends op{_tag=e},m$=()=>new Proxy({},{get(e,t,n){return t===\"$is\"?$t:t===\"$match\"?h$:r=>({...r,_tag:t})}});function h$(){if(arguments.length===1){let n=arguments[0];return function(r){return n[r._tag](r)}}let e=arguments[0];return arguments[1][e._tag](e)}var zT=Fu,yo=Wo;var WT=\"~effect/encoding/EncodingError\",Go=class extends yo(\"EncodingError\"){[WT]=WT};var ip=e=>Rx(typeof e==\"string\"?Fx.encode(e):e),tc=e=>{let t=ev(e),n=t.length;if(n%4!==0)return re(new Go({kind:\"Decode\",module:\"Base64\",input:t,message:`Length must be a multiple of 4, but is ${n}`}));let r=t.indexOf(\"=\");if(r!==-1&&(r$e()(st([J({_tag:ze(\"Some\"),value:n}),J({_tag:ze(\"None\")})]),We({decode:r=>r._tag===\"None\"?R():k(r.value),encode:r=>_e(r)?{_tag:\"Some\",value:r.value}:{_tag:\"None\"}})),toArbitrary:([n])=>(r,o)=>{let s=r.constant(R()),i=r.oneof(s,n.arbitrary.map(k));return Ra(r,o,s,i)},toEquivalence:([n])=>ex(n),toFormatter:([n])=>fr({onNone:()=>\"none()\",onSome:r=>`some(${n(r)})`})});return j(t.ast,{value:e})}var O3=wn(\"effect/schema/Option\",Re,({annotations:e,typeParameters:t})=>{let n=si(t[0]);return e===void 0?n:n.annotate(e)});function k3(e){return rw(e).pipe(ne(si(Tn(e)),oR()))}function R3(e){return ng(e).pipe(ne(si(Tn(e)),sR()))}function _3(e,t){return tU(e).pipe(ne(si(Tn(e)),iR(t)))}function M3(e){return Ca(e).pipe(ne(si(Tn(e)),aR()))}function P3(e){return Ar(e).pipe(ne(si(Tn(e)),cR()))}function F3(e,t){let n=t===void 0?\"omit\":t.onNoneEncoding,r=n===null?null:void 0;return Ar(rw(e)).pipe(ne(si(Tn(e)),Mm({decode:o=>o.pipe(Ai(vd),k),encode:n===\"omit\"?vi:o=>k(ju(vi(o),()=>r))})))}function YU(e,t){let n=Nn()([e,t],([r,o])=>(s,i,a)=>{if(!X0(s))return P(new He(i,s,a));switch(s._tag){case\"Success\":return Ut(OI(r)(s.success,a),{onSuccess:se,onFailure:c=>Pn(i,\"success\",c,s,a)});case\"Failure\":return Ut(OI(o)(s.failure,a),{onSuccess:re,onFailure:c=>Pn(i,\"failure\",c,s,a)})}},{representation:{id:\"effect/schema/Result\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Result(${r[0].runtime}, ${r[1].runtime})`,Type:`Result.Result<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Result from \"effect/Result\"']}),expected:\"Result\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Success\"),success:r}),J({_tag:ze(\"Failure\"),failure:o})]),We({decode:s=>s._tag===\"Success\"?se(s.success):re(s.failure),encode:s=>Tt(s)?{_tag:\"Success\",success:s.success}:{_tag:\"Failure\",failure:s.failure}})),toArbitrary:([r,o])=>(s,i)=>{let a=cN(s,r.terminal?.map(u=>se(u)),o.terminal?.map(u=>re(u))),c=s.oneof(r.arbitrary.map(u=>se(u)),o.arbitrary.map(u=>re(u)));return Ra(s,i,a,c)},toEquivalence:([r,o])=>eT(r,o),toFormatter:([r,o])=>lr({onSuccess:s=>`success(${r(s)})`,onFailure:s=>`failure(${o(s)})`})});return j(n.ast,{success:e,failure:t})}var U3=wn(\"effect/schema/Result\",Re,({annotations:e,typeParameters:t})=>{let n=YU(t[0],t[1]);return e===void 0?n:n.annotate(e)}),N3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>{switch(n){case\"label\":return typeof e[n]==\"string\";case\"disallowJsonEncode\":return e[n]===!0;default:return!1}})}),D3=st([Re,N3]);function ed(e,t){let n=typeof t?.label==\"string\"?t.label:void 0,r=t?.disallowJsonEncode===!0,o=n!==void 0?r?{label:n,disallowJsonEncode:!0}:{label:n}:r?{disallowJsonEncode:!0}:void 0,s=n!==void 0?Ze(ze(n)):void 0,i=Nn()([e],([a])=>(c,u,f)=>{if(GI(c)){let d=s!==void 0?fs(s(c.label,f),p=>new Ve([\"label\"],p)):te;return lt(d,()=>Ut(Ze(a)(eu(c),f),{onSuccess:()=>c,onFailure:()=>new nt(u,[new Ve([\"value\"],new ge(void 0,c,f))],c,f)}))}return P(new He(u,c,f))},{representation:{id:\"effect/schema/Redacted\",payload:o??null},toCode:({typeParameters:a})=>({runtime:o!==void 0?`Schema.Redacted(${a[0].runtime}, ${N(o)})`:`Schema.Redacted(${a[0].runtime})`,Type:`Redacted.Redacted<${a[0].Type}>`,importDeclarations:['import * as Redacted from \"effect/Redacted\"']}),expected:\"Redacted\",toCodecJson:([a])=>$e()(a,{decode:ce(c=>zl(c,{label:n})),encode:r?ES(c=>\"Cannot serialize Redacted\"+(_e(c)&&typeof c.value.label==\"string\"?` with label: \"${c.value.label}\"`:\"\")):ce(eu)}),toArbitrary:([a])=>()=>({arbitrary:a.arbitrary.map(c=>zl(c,{label:n})),terminal:a.terminal?.map(c=>zl(c,{label:n}))}),toFormatter:()=>globalThis.String,toEquivalence:([a])=>SF(a)});return j(i.ast,{value:e})}var L3=wn(\"effect/schema/Redacted\",D3,({annotations:e,payload:t,typeParameters:n})=>{let r=ed(n[0],t??void 0);return e===void 0?r:r.annotate(e)});function $3(e,t){return ne(ed(Tn(e),{label:t?.label,disallowJsonEncode:t?.disallowEncode}),{decode:ce(n=>zl(n,{label:t?.label})),encode:t?.disallowEncode?ES(n=>\"Cannot encode Redacted\"+(_e(n)&&typeof n.value.label==\"string\"?` with label: \"${n.value.label}\"`:\"\")):ce(eu)})(e)}function Vh(e,t){let n=Nn()([e,t],([r,o])=>(s,i,a)=>{if(!eb(s))return P(new He(i,s,a));switch(s._tag){case\"Fail\":return Ut(Ze(r)(s.error,a),{onSuccess:Ef,onFailure:c=>Pn(i,\"error\",c,s,a)});case\"Die\":return Ut(Ze(o)(s.defect,a),{onSuccess:If,onFailure:c=>Pn(i,\"defect\",c,s,a)});case\"Interrupt\":return x(s)}},{representation:{id:\"effect/schema/CauseReason\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.CauseReason(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Failure<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause.Failure\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Fail\"),error:r}),J({_tag:ze(\"Die\"),defect:o}),J({_tag:ze(\"Interrupt\"),fiberId:ng(Xn)})]),We({decode:s=>{switch(s._tag){case\"Fail\":return Ef(s.error);case\"Die\":return If(s.defect);case\"Interrupt\":return wf(s.fiberId)}},encode:_})),toArbitrary:([r,o])=>QU(r,o),toEquivalence:([r,o])=>XU(r,o),toFormatter:([r,o])=>eN(r,o)});return j(n.ast,{error:e,defect:t})}var j3=wn(\"effect/schema/CauseReason\",Re,({annotations:e,typeParameters:t})=>{let n=Vh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function QU(e,t){return(n,r)=>{let o=n.constant(wf()),s=n.oneof(o,n.integer({min:1}).map(wf),e.arbitrary.map(i=>Ef(i)),t.arbitrary.map(i=>If(i)));return Ra(n,r,o,s)}}function XU(e,t){return(n,r)=>{if(n._tag!==r._tag)return!1;switch(n._tag){case\"Fail\":return e(n.error,r.error);case\"Die\":return t(n.defect,r.defect);case\"Interrupt\":return n.fiberId===r.fiberId}}}function eN(e,t){return n=>{switch(n._tag){case\"Fail\":return`Fail(${e(n.error)})`;case\"Die\":return`Die(${t(n.defect)})`;case\"Interrupt\":return\"Interrupt\"}}}function Zh(e,t){let n=Nn()([e,t],([r,o])=>{let s=Ye(Vh(r,o));return(i,a,c)=>Xy(i)?Ut(Ze(s)(i.reasons,c),{onSuccess:ji,onFailure:u=>Pn(a,\"failures\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/Cause\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Cause(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Cause<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause\",toCodec:([r,o])=>$e()(Ye(Vh(r,o)),We({decode:ji,encode:({reasons:s})=>s})),toArbitrary:([r,o])=>tN(r,o),toEquivalence:([r,o])=>nN(r,o),toFormatter:([r,o])=>rN(r,o)});return j(n.ast,{error:e,defect:t})}var B3=wn(\"effect/schema/Cause\",Re,({annotations:e,typeParameters:t})=>{let n=Zh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function tN(e,t){return(n,r)=>{let o=QU(e,t)(n,r),s=n.constant(tb),i=n.array(o.arbitrary).map(ji);return Ra(n,r,s,i)}}function nN(e,t){let n=A0(XU(e,t));return(r,o)=>n(r.reasons,o.reasons)}function rN(e,t){let n=eN(e,t);return r=>`Cause([${r.reasons.map(n).join(\", \")}])`}var q3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>(n===\"includeStack\"||n===\"excludeCause\")&&e[n]===!0)}),z3=st([Re,q3]),oN=e=>(e?.includeStack===!0?1:0)|(e?.excludeCause===!0?2:0),sN=e=>{switch(e){case 0:return;case 1:return{includeStack:!0};case 2:return{excludeCause:!0};case 3:return{includeStack:!0,excludeCause:!0}}},RF=[];function iN(e){let t=oN(e),n=RF[t];if(n!==void 0)return n;let r=sN(t),o=oi(globalThis.Error,{representation:{id:\"effect/schema/Error\",payload:r??null},toCode:()=>({runtime:r!==void 0?`Schema.ErrorInstance(${N(r)})`:\"Schema.ErrorInstance()\",Type:\"globalThis.Error\"}),expected:\"Error\",toCodecJson:()=>$e()(N6,nR(r)),toArbitrary:()=>s=>s.string().map(i=>new globalThis.Error(i))});return RF[t]=o,o}var W3=wn(\"effect/schema/Error\",z3,({annotations:e,payload:t})=>{let n=iN(t??void 0);return e===void 0?n:n.annotate(e)}),_F=[];function H3(e){let t=oN(e),n=_F[t];if(n!==void 0)return n;let r=id.pipe(ne(ew,rR(sN(t))));return _F[t]=r,r}function aN(e,t,n){let r=Nn()([e,t,n],([o,s,i])=>{let a=Zh(s,i);return(c,u,f)=>{if(!vf(c))return P(new He(u,c,f));switch(c._tag){case\"Success\":return Ut(Ze(o)(c.value,f),{onSuccess:Yt,onFailure:d=>Pn(u,\"value\",d,c,f)});case\"Failure\":return Ut(Ze(a)(c.cause,f),{onSuccess:Qt,onFailure:d=>Pn(u,\"cause\",d,c,f)})}}},{representation:{id:\"effect/schema/Exit\",payload:null},toCode:({typeParameters:o})=>({runtime:`Schema.Exit(${o[0].runtime}, ${o[1].runtime}, ${o[2].runtime})`,Type:`Exit.Exit<${o[0].Type}, ${o[1].Type}, ${o[2].Type}>`,importDeclarations:['import * as Exit from \"effect/Exit\"']}),expected:\"Exit\",toCodec:([o,s,i])=>$e()(st([J({_tag:ze(\"Success\"),value:o}),J({_tag:ze(\"Failure\"),cause:Zh(s,i)})]),We({decode:a=>a._tag===\"Success\"?Yt(a.value):Qt(a.cause),encode:a=>et(a)?{_tag:\"Success\",value:a.value}:{_tag:\"Failure\",cause:a.cause}})),toArbitrary:([o,s,i])=>(a,c)=>{let u=tN(s,i)(a,c),f=cN(a,o.terminal?.map(p=>Yt(p)),u.terminal?.map(p=>Qt(p))),d=a.oneof(o.arbitrary.map(p=>Yt(p)),u.arbitrary.map(p=>Qt(p)));return Ra(a,c,f,d)},toEquivalence:([o,s,i])=>{let a=nN(s,i);return(c,u)=>{if(c._tag!==u._tag)return!1;switch(c._tag){case\"Success\":return o(c.value,u.value);case\"Failure\":return a(c.cause,u.cause)}}},toFormatter:([o,s,i])=>{let a=rN(s,i);return c=>{switch(c._tag){case\"Success\":return`Exit.Success(${o(c.value)})`;case\"Failure\":return`Exit.Failure(${a(c.cause)})`}}}});return j(r.ast,{value:e,error:t,defect:n})}var J3=wn(\"effect/schema/Exit\",Re,({annotations:e,typeParameters:t})=>{let n=aN(t[0],t[1],t[2]);return e===void 0?n:n.annotate(e)});function cN(e,t,n){return t===void 0?n:n===void 0?t:e.oneof(t,n)}function Ra(e,t,n,r){return{arbitrary:n===void 0||t.recursion===void 0?r:e.oneof(t.recursion,n,r),terminal:n}}function MF(e,t,n,r){return r===void 0?e.array(t,n):e.uniqueArray(t,{...n,comparator:r})}function og(e,t,n,r,o,s){let i=t.constraint,a=i===void 0||i.minLength===void 0&&i.maxLength===void 0?void 0:{...i.minLength!==void 0?{minLength:i.minLength}:{},...i.maxLength!==void 0?{maxLength:i.maxLength}:{}};if(a?.minLength!==void 0&&a.maxLength!==void 0&&a.minLength>a.maxLength)throw new globalThis.Error(\"Unable to derive an arbitrary for size constraints\");let c=a?.minLength??0,u=c===0?e.constant([]):r===void 0?void 0:MF(e,r,{...a,maxLength:c},s),f=Ra(e,t,u,MF(e,n,a,s));return{arbitrary:f.arbitrary.map(o),terminal:f.terminal?.map(o)}}function uN(e,t,n,r,o){return og(e,t,e.tuple(n.arbitrary,r.arbitrary),n.terminal===void 0||r.terminal===void 0?void 0:e.tuple(n.terminal,r.terminal),o,([s],[i])=>B(s,i))}function fN(e,t){let n=Nn()([e,t],([r,o])=>{let s=Ye(nu([r,o]));return(i,a,c)=>i instanceof globalThis.Map?Ut(Ze(s)([...i],c),{onSuccess:u=>new globalThis.Map(u),onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/ReadonlyMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.ReadonlyMap(${r[0].runtime}, ${r[1].runtime})`,Type:`globalThis.ReadonlyMap<${r[0].Type}, ${r[1].Type}>`}),expected:\"ReadonlyMap\",toCodec:([r,o])=>$e()(Ye(nu([r,o])),We({decode:s=>new globalThis.Map(s),encode:s=>[...s.entries()]})),toArbitrary:([r,o])=>(s,i)=>uN(s,i,r,o,a=>new globalThis.Map(a)),toEquivalence:([r,o])=>Od(r,o),toFormatter:([r,o])=>s=>{let i=s.size;if(i===0)return\"ReadonlyMap(0) {}\";let a=globalThis.Array.from(s.entries()).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`ReadonlyMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var K3=wn(\"effect/schema/ReadonlyMap\",Re,({annotations:e,typeParameters:t})=>{let n=fN(t[0],t[1]);return e===void 0?n:n.annotate(e)});function PF(e,t,n){return J({type:ze(e),nodes:Ye(J({index:Gt,data:t})),edges:Ye(J({index:Gt,source:Gt,target:Gt,data:n}))})}function FF(e,t){let n=-1,r=new Set;for(let o=0;o{let o=Ol(n),s=Ol(r);if(o.type!==s.type||o.nodes.length!==s.nodes.length||o.edges.length!==s.edges.length)return!1;for(let i=0;i{let s=kl({type:e,nodes:[],edges:[]}),i=r.constant(s),a=r.array(t.arbitrary).chain(c=>{let u=c.map((d,p)=>({index:p,data:d}));if(u.length===0)return i;let f=r.integer({min:0,max:u.length-1});return r.array(r.tuple(f,f,n.arbitrary)).map(d=>kl({type:e,nodes:u,edges:d.map(([p,m,g],b)=>({index:b,source:p,target:m,data:g}))}))});return Ra(r,o,i,a)}}function lN(e,t,n){let r=Nn()([t,n],([o,s])=>{let i=PF(e,o,s);return(a,c,u)=>!xI(a)||a.mutable||a.type!==e?P(new He(c,a,u)):v(Ze(i)(Ol(a),u),f=>FF(f,u))},{representation:{id:\"effect/schema/Graph\",payload:e},toCode:({typeParameters:o})=>({runtime:`Schema.Graph(${N(e)}, ${o[0].runtime}, ${o[1].runtime})`,Type:`Graph.Graph<${o[0].Type}, ${o[1].Type}, ${N(e)}>`,importDeclarations:['import * as Graph from \"effect/Graph\"']}),expected:`an immutable ${e} Graph`,toCodec:([o,s])=>$e()(PF(e,o,s),Zn({decode:FF,encode:(i,a)=>G3(i,e,a)})),toArbitrary:([o,s])=>Z3(e,o,s),toEquivalence:([o,s])=>V3(o,s),toFormatter:()=>globalThis.String});return j(r.ast,{type:e,node:t,edge:n})}var Y3=wn(\"effect/schema/Graph\",so([\"directed\",\"undirected\"]),({annotations:e,payload:t,typeParameters:n})=>{let r=lN(t,n[0],n[1]);return e===void 0?r:r.annotate(e)});function dN(e,t){let n=Nn()([e,t],([r,o])=>{let s=Ye(nu([r,o]));return(i,a,c)=>QM(i)?Ut(Ze(s)(Fh(i),c),{onSuccess:Ph,onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/HashMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.HashMap(${r[0].runtime}, ${r[1].runtime})`,Type:`HashMap.HashMap<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as HashMap from \"effect/HashMap\"']}),expected:\"HashMap\",toCodec:([r,o])=>$e()(Ye(nu([r,o])),We({decode:Ph,encode:Fh})),toArbitrary:([r,o])=>(s,i)=>uN(s,i,r,o,Ph),toEquivalence:([r,o])=>Od(r,o),toFormatter:([r,o])=>s=>{let i=XM(s);if(i===0)return\"HashMap(0) {}\";let a=Fh(s).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`HashMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var Q3=wn(\"effect/schema/HashMap\",Re,({annotations:e,typeParameters:t})=>{let n=dN(t[0],t[1]);return e===void 0?n:n.annotate(e)});function pN(e){let t=Nn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>o instanceof globalThis.Set?Ut(Ze(r)([...o],i),{onSuccess:a=>new globalThis.Set(a),onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/ReadonlySet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.ReadonlySet(${n[0].runtime})`,Type:`globalThis.ReadonlySet<${n[0].Type}>`}),expected:\"ReadonlySet\",toCodec:([n])=>$e()(Ye(n),We({decode:r=>new globalThis.Set(r),encode:r=>[...r.values()]})),toArbitrary:([n])=>(r,o)=>og(r,o,n.arbitrary,n.terminal,s=>new globalThis.Set(s),B),toEquivalence:([n])=>kd(n),toFormatter:([n])=>r=>{let o=r.size;if(o===0)return\"ReadonlySet(0) {}\";let s=globalThis.Array.from(r.values()).sort().map(i=>`${n(i)}`);return`ReadonlySet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var X3=wn(\"effect/schema/ReadonlySet\",Re,({annotations:e,typeParameters:t})=>{let n=pN(t[0]);return e===void 0?n:n.annotate(e)});function mN(e){let t=Nn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>oP(o)?Ut(Ze(r)(Be(o),i),{onSuccess:Nh,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/HashSet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.HashSet(${n[0].runtime})`,Type:`HashSet.HashSet<${n[0].Type}>`}),expected:\"HashSet\",toCodec:([n])=>$e()(Ye(n),We({decode:Nh,encode:Be})),toArbitrary:([n])=>(r,o)=>og(r,o,n.arbitrary,n.terminal,Nh,B),toEquivalence:([n])=>kd(n),toFormatter:([n])=>r=>{let o=sP(r);if(o===0)return\"HashSet(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`HashSet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var eG=wn(\"effect/schema/HashSet\",Re,({annotations:e,typeParameters:t})=>{let n=mN(t[0]);return e===void 0?n:n.annotate(e)});function hN(e){let t=Nn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>Qm(o)?Ut(Ze(r)(Be(o),i),{onSuccess:Xm,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/Chunk\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.Chunk(${n[0].runtime})`,Type:`Chunk.Chunk<${n[0].Type}>`}),expected:\"Chunk\",toCodec:([n])=>$e()(Ye(n),We({decode:Xm,encode:Be})),toArbitrary:([n])=>(r,o)=>og(r,o,n.arbitrary,n.terminal,Xm),toEquivalence:([n])=>TE(n),toFormatter:([n])=>r=>{let o=l_(r);if(o===0)return\"Chunk(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`Chunk(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var tG=wn(\"effect/schema/Chunk\",Re,({annotations:e,typeParameters:t})=>{let n=hN(t[0]);return e===void 0?n:n.annotate(e)}),gN=oi(globalThis.RegExp,{representation:{id:\"effect/schema/RegExp\",payload:null},toCode:()=>({runtime:\"Schema.RegExp\",Type:\"globalThis.RegExp\"}),expected:\"RegExp\",toCodecJson:()=>$e()(J({source:X,flags:X}),Zn({decode:(e,t)=>yc({try:()=>new globalThis.RegExp(e.source,e.flags),catch:()=>new ge({expected:\"valid RegExp source and flags\"},e,t)}),encode:e=>x({source:e.source,flags:e.flags})})),toArbitrary:()=>e=>e.tuple(e.constantFrom(\".\",\".*\",\"\\\\d+\",\"\\\\w+\",\"[a-z]+\",\"[A-Z]+\",\"[0-9]+\",\"^[a-zA-Z0-9]+$\",\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"),e.uniqueArray(e.constantFrom(\"g\",\"i\",\"m\",\"s\",\"u\",\"y\"),{minLength:0,maxLength:6}).map(t=>t.join(\"\"))).map(([t,n])=>new globalThis.RegExp(t,n)),toEquivalence:()=>(e,t)=>e.source===t.source&&e.flags===t.flags}),nG=sn(\"effect/schema/RegExp\",gN),xN=X.annotate({expected:\"a string that will be decoded as a URL\"}),td=oi(globalThis.URL,{representation:{id:\"effect/schema/URL\",payload:null},toCode:()=>({runtime:\"Schema.URL\",Type:\"globalThis.URL\"}),expected:\"URL\",toCodecJson:()=>$e()(xN,OS),toArbitrary:()=>e=>e.webUrl().map(t=>new globalThis.URL(t)),toEquivalence:()=>(e,t)=>e.toString()===t.toString()}),rG=sn(\"effect/schema/URL\",td),oG=xN.pipe(ne(td,OS));function pw(e,t,n){let r={...t};if(e?.minimum!==void 0){let o=n===void 0?e.minimum:n(e.minimum),s=e.exclusiveMinimum?new globalThis.Date(o.getTime()+1):o;(r.min===void 0||s.getTime()>r.min.getTime())&&(r.min=s)}if(e?.maximum!==void 0){let o=n===void 0?e.maximum:n(e.maximum),s=e.exclusiveMaximum?new globalThis.Date(o.getTime()-1):o;(r.max===void 0||s.getTime()e instanceof globalThis.Date&&!globalThis.Number.isNaN(e.getTime()),{representation:{id:\"effect/schema/Date\",payload:null},toCode:()=>({runtime:\"Schema.Date\",Type:\"globalThis.Date\"}),expected:\"a valid Date\",toCodecJson:()=>$e()(yN,CS),toArbitrary:()=>(e,t)=>e.date(pw(t?.constraint?.ordered?.order===Ii?t.constraint.ordered:void 0,{noInvalidDate:!0}))}),sG=sn(\"effect/schema/Date\",er),iG=yN.pipe(ne(er,CS)),aG=Uo.pipe(ne(er,Vk)),nd=Cr(Tx,{representation:{id:\"effect/schema/Duration\",payload:null},toCode:()=>({runtime:\"Schema.Duration\",Type:\"Duration.Duration\",importDeclarations:['import * as Duration from \"effect/Duration\"']}),expected:\"Duration\",toCodecJson:()=>$e()(st([J({_tag:ze(\"Infinity\")}),J({_tag:ze(\"NegativeInfinity\")}),J({_tag:ze(\"Nanos\"),value:Fo}),J({_tag:ze(\"Millis\"),value:Uo})]),We({decode:e=>{switch(e._tag){case\"Infinity\":return Fr;case\"NegativeInfinity\":return ki;case\"Nanos\":return dr(e.value);case\"Millis\":return go(e.value)}},encode:e=>{switch(e.value._tag){case\"Infinity\":return{_tag:\"Infinity\"};case\"NegativeInfinity\":return{_tag:\"NegativeInfinity\"};case\"Nanos\":return{_tag:\"Nanos\",value:e.value.nanos};case\"Millis\":return{_tag:\"Millis\",value:e.value.millis}}}})),toArbitrary:()=>e=>e.oneof(e.constant(Fr),e.constant(ki),e.bigInt().map(dr),e.maxSafeInteger().map(go)),toFormatter:()=>globalThis.String,toEquivalence:()=>vx}),cG=sn(\"effect/schema/Duration\",nd),uG=X.annotate({expected:\"a string that will be decoded as a Duration\"}),mw=uG.pipe(ne(nd,Zk)),fG=Fo.pipe(ne(nd,Yk)),lG=au.pipe(ne(nd,Qk)),bN=X.annotate({expected:\"a string that will be decoded as a BigDecimal\"}),SN=20,EN=\"Unable to derive an arbitrary for the ordered BigDecimal constraints\";function Yh(e,t){return _s(e,t).value}function dG(e,t,n){return n?Yh(mx(e,t),t)+globalThis.BigInt(1):Yh(px(e,t),t)}function pG(e,t,n){return n?Yh(px(e,t),t)-globalThis.BigInt(1):Yh(mx(e,t),t)}function mG(e){return Math.max(SN,e.minimum?.scale??0,e.maximum?.scale??0,e.exclusiveMinimum&&e.minimum!==void 0?e.minimum.scale+1:0,e.exclusiveMaximum&&e.maximum!==void 0?e.maximum.scale+1:0)}function ZI(e,t){let n={};if(e.minimum!==void 0&&(n.min=dG(e.minimum,t,e.exclusiveMinimum===!0)),e.maximum!==void 0&&(n.max=pG(e.maximum,t,e.exclusiveMaximum===!0)),!(n.min!==void 0&&n.max!==void 0&&n.min>n.max))return n}function hG(e){let t=mG(e);if(ZI(e,t)===void 0)throw new globalThis.Error(EN);let n=0,r=t;for(;n({runtime:\"Schema.BigDecimal\",Type:\"BigDecimal.BigDecimal\",importDeclarations:['import * as BigDecimal from \"effect/BigDecimal\"']}),expected:\"BigDecimal\",toCodecJson:()=>$e()(bN,kS),toArbitrary:()=>(e,t)=>{let n=t.constraint?.ordered?.order===Jo?t.constraint.ordered:void 0;return n===void 0?e.tuple(e.bigInt(),e.integer({min:0,max:SN})).map(([r,o])=>Rn(r,o)):e.integer(hG(n)).chain(r=>{let o=ZI(n,r);if(o===void 0)throw new globalThis.Error(EN);return e.bigInt(o).map(s=>Rn(s,r))})},toFormatter:()=>e=>mo(e),toEquivalence:()=>lx}),gG=sn(\"effect/schema/BigDecimal\",hw),xG=bN.pipe(ne(hw,kS)),yG=X.annotate({expected:\"a string that will be decoded as JSON\",contentMediaType:\"application/json\"});function IN(e,t){return yG.pipe(ne(e,pR(t)))}var bG=IN(ew),gw=oi(globalThis.File,{representation:{id:\"effect/schema/File\",payload:null},toCode:()=>({runtime:\"Schema.File\",Type:\"globalThis.File\"}),expected:\"File\",toCodecJson:()=>$e()(J({data:X.check(aw()),type:X,name:X,lastModified:Uo}),Zn({decode:(e,t)=>lr(tc(e.data),{onFailure:()=>P(new ge({expected:\"a valid Base64 string\"},e.data,t)),onSuccess:n=>{let r=new globalThis.Uint8Array(n);return x(new globalThis.File([r],e.name,{type:e.type,lastModified:e.lastModified}))}}),encode:(e,t)=>xc({try:async()=>{let n=new globalThis.Uint8Array(await e.arrayBuffer());return{data:ip(n),type:e.type,name:e.name,lastModified:e.lastModified}},catch:()=>new ge({expected:\"a readable File\"},e,t)})}))}),SG=sn(\"effect/schema/File\",gw),xw=oi(globalThis.FormData,{representation:{id:\"effect/schema/FormData\",payload:null},toCode:()=>({runtime:\"Schema.FormData\",Type:\"globalThis.FormData\"}),expected:\"FormData\",toCodecJson:()=>$e()(Ye(nu([X,st([J({_tag:Oa(\"String\"),value:X}),J({_tag:Oa(\"File\"),value:gw})])])),Zn({decode:e=>{let t=new globalThis.FormData;for(let[n,r]of e)t.append(n,r.value);return x(t)},encode:e=>x(globalThis.Array.from(e.entries()).map(([t,n])=>typeof n==\"string\"?[t,{_tag:\"String\",value:n}]:[t,{_tag:\"File\",value:n}]))}))}),EG=sn(\"effect/schema/FormData\",xw);function IG(e){return xw.pipe(ne(e,mR))}var yw=oi(globalThis.URLSearchParams,{representation:{id:\"effect/schema/URLSearchParams\",payload:null},toCode:()=>({runtime:\"Schema.URLSearchParams\",Type:\"globalThis.URLSearchParams\"}),expected:\"URLSearchParams\",toCodecJson:()=>$e()(X.annotate({expected:\"a query string that will be decoded as URLSearchParams\"}),We({decode:e=>new globalThis.URLSearchParams(e),encode:e=>e.toString()}))}),wG=sn(\"effect/schema/URLSearchParams\",yw);function TG(e){return yw.pipe(ne(e,hR))}var vG=X.annotate({expected:\"a string that will be decoded as a number\"}).pipe(ne(au,kc)),AG=X.annotate({expected:\"a string that will be decoded as a finite number\"}).pipe(ne(Xn,kc)),CG=j(xE).pipe(ne(Fo,Pm)),wN=X.check(iw()),OG=X.annotate({expected:\"a string that will be decoded as a trimmed string\"}).pipe(ne(wN,Kk())),kG=X.annotate({expected:\"a base64 encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,uR)),RG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,fR)),_G=X.annotate({expected:\"a hex encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,lR)),MG=X.annotate({expected:\"a URI component encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,dR)),YI=st([Xn,QF,X]),PG=J({issues:Ye(J({message:X,path:Ar(Ye(st([YI,J({key:YI})])))}))}),FG=so([0,1]).pipe(ne(tg,We({decode:e=>e===1,encode:e=>e?1:0}))),TN=X.annotate({expected:\"a base64 encoded string that will be decoded as Uint8Array\",format:\"byte\",contentEncoding:\"base64\"}),rd=oi(globalThis.Uint8Array,{representation:{id:\"effect/schema/Uint8Array\",payload:null},toCode:()=>({runtime:\"Schema.Uint8Array\",Type:\"globalThis.Uint8Array\"}),expected:\"Uint8Array\",toCodecJson:()=>$e()(TN,RS),toArbitrary:()=>e=>e.uint8Array()}),UG=sn(\"effect/schema/Uint8Array\",rd),NG=TN.pipe(ne(rd,RS)),DG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a Uint8Array\"}).pipe(ne(rd,{decode:Uk(),encode:km()})),LG=X.annotate({expected:\"a hex encoded string that will be decoded as a Uint8Array\"}).pipe(ne(rd,{decode:Dk(),encode:Rm()})),od=Cr(e=>xS(e)&&hk(e),{representation:{id:\"effect/schema/DateTimeUtc\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeUtc\",Type:\"DateTime.Utc\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Utc\",toCodecJson:()=>$e()(X,PS),toArbitrary:()=>(e,t)=>e.date(pw(t?.constraint?.ordered?.order===bS?t.constraint.ordered:void 0,{noInvalidDate:!0},Tm)).map(n=>xk(n)),toFormatter:()=>e=>e.toString(),toEquivalence:()=>yS}),$G=sn(\"effect/schema/DateTimeUtc\",od),jG=er.pipe(ne(od,{decode:AS(),encode:ce(Tm)})),BG=X.annotate({expected:\"a string that will be decoded as a DateTime.Utc\"}).pipe(ne(od,PS)),qG=Uo.pipe(ne(od,{decode:AS(),encode:ce(Ik)})),vN=Cr(pk,{representation:{id:\"effect/schema/TimeZoneOffset\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneOffset\",Type:\"DateTime.TimeZone.Offset\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Offset\",toCodecJson:()=>$e()(Uo,gR),toArbitrary:()=>e=>e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Qf(t)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.offset===t.offset}),zG=sn(\"effect/schema/TimeZoneOffset\",vN),AN=X.annotate({expected:\"an IANA time zone identifier\"}),bw=Cr(mk,{representation:{id:\"effect/schema/TimeZoneNamed\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneNamed\",Type:\"DateTime.TimeZone.Named\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Named\",toCodecJson:()=>$e()(AN,_S),toArbitrary:()=>e=>e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(SS)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.id===t.id}),WG=sn(\"effect/schema/TimeZoneNamed\",bw),HG=AN.pipe(ne(bw,_S)),CN=X.annotate({expected:\"a time zone string (IANA identifier or offset like +03:00)\"}),Sw=Cr(dk,{representation:{id:\"effect/schema/TimeZone\",payload:null},toCode:()=>({runtime:\"Schema.TimeZone\",Type:\"DateTime.TimeZone\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone\",toCodecJson:()=>$e()(CN,MS),toArbitrary:()=>e=>e.oneof(e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Qf(t)),e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(SS))),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>Vs(e)===Vs(t)}),JG=sn(\"effect/schema/TimeZone\",Sw),KG=CN.pipe(ne(Sw,MS)),ON=X.annotate({expected:\"a zoned DateTime string (e.g. 2024-01-01T00:00:00.000+00:00[Europe/London])\"}),Ew=Cr(e=>xS(e)&&gk(e),{representation:{id:\"effect/schema/DateTimeZoned\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeZoned\",Type:\"DateTime.Zoned\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Zoned\",toCodecJson:()=>$e()(ON,FS),toArbitrary:()=>(e,t)=>e.tuple(e.date(pw(t?.constraint?.ordered?.order===bS?t.constraint.ordered:void 0,{max:new globalThis.Date(864e13-840*60*1e3),min:new globalThis.Date(-864e13+840*60*1e3),noInvalidDate:!0},Tm)),e.constantFrom(\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\")).map(([n,r])=>yk(n,{timeZone:r})),toFormatter:()=>e=>vm(e),toEquivalence:()=>yS}),GG=sn(\"effect/schema/DateTimeZoned\",Ew),VG=ON.pipe(ne(Ew,FS)),ZG=globalThis.Symbol.for(\"immer-draftable\"),QI={};function Iw(e,t,n,r,o){let s=QG(n,t,r),i=kN(t),a=class extends e{constructor(...[c,u]){let d=u?.[\"~payload\"],p=d?.token===QI?d.value:n.make(c??{},u);super(p,{...u,disableChecks:!0,\"~payload\":{token:QI,value:p}})}static[Wl]=Wl;get[i](){return i}static[ZG]=!0;static identifier=t;static fields=n.fields;static get ast(){return s(this).ast}static pipe(){return K(this,arguments)}static rebuild(c){return s(this).rebuild(c)}static make(c,u){return new this(c,u)}static makeOption(c,u){return Lh(s(this))(c??{},u)}static makeEffect(c,u){return s(this).makeEffect(c??{},u)}static annotate(c){return this.rebuild(Ir(this.ast,c))}static annotateKey(c){return this.rebuild(dl(this.ast,c))}static check(...c){return this.rebuild(ko(this.ast,c))}static extend(c){return(u,f)=>{let d=sd(u)?u:J(u),p={...n.fields,...d.fields},m=qm(p,n.ast.checks,{identifier:c});return Iw(this,c,nw(ko(m,d.ast.checks),p),f,o)}}static mapFields(c,u){return n.mapFields(c,u)}};return o!==void 0&&Object.assign(a.prototype,o(t)),a}function YG(e){return new Te(ce(t=>new e(t,{\"~payload\":{token:QI,value:t}})),Sn())}function kN(e){return`~effect/Schema/Class/${e}`}function QG(e,t,n){let r;return o=>{if(r!==void 0)return r;let s=kN(t),i=u=>u instanceof o||M(u,s),a=YG(o),c=j(new sa([e.ast],()=>(u,f,d)=>i(u)?x(u):P(new He(f,u,d)),{identifier:t,[gm]:([u])=>({isConstructed:i,link:new Je(u,a)}),toCodec:([u])=>new Je(u.ast,a),toArbitrary:([u])=>()=>({arbitrary:u.arbitrary.map(f=>new o(f)),terminal:u.terminal?.map(f=>new o(f))}),toFormatter:([u])=>f=>`${o.identifier}(${u(f)})`,[Jf]:Mc(e.ast),...n}));return r=ne(c,a)(e)}}function sd(e){return eg(e)}var RN=e=>(t,n)=>{let r=sd(t)?t:J(t);return Iw(op,e,r,n,o=>({toString(){return`${o}(${N({...this})})`}}))},XG=e=>(t,n,r)=>{let o=sd(n)?n.mapFields(s=>({_tag:Oa(t),...s}),{unsafePreserveChecks:!0}):rg(t,n);return RN(e??t)(o,r)},_N=e=>(t,n)=>{let r=sd(t)?t:J(t);return Iw(Fu,e,r,n,s=>({name:s}))},e6=e=>(t,n,r)=>{let o=sd(n)?n.mapFields(s=>({_tag:Oa(t),...s}),{unsafePreserveChecks:!0}):rg(t,n);return _N(e??t)(o,r)};function t6(e){let t=JP(e.ast);return n=>t(n,{})}function n6(e){return t=>t.annotate({toFormatter:e})}function r6(e,t){return n(e.ast);function n(o){let s=Co(o)?.toFormatter;if(typeof s==\"function\")return s(Qs(o)?o.typeParameters.map(n):[]);if(t?.onBefore){let i=t.onBefore(o,n);if(i!==void 0)return i}return r(o)}function r(o){switch(o._tag){default:return N;case\"Never\":return()=>\"never\";case\"Void\":return()=>\"void\";case\"Arrays\":{let s=o.elements.map(n),i=o.rest.map(n);return a=>{let c=[],u=0;for(;u0){let[f,...d]=i;for(;un(a.type)),i=o.indexSignatures.map(a=>n(a.type));return o.propertySignatures.length===0&&o.indexSignatures.length===0?N:a=>{let c=[],u=new Set;for(let f=0;f0?\"{ \"+c.join(\", \")+\" }\":\"{}\"}}case\"Union\":{let s=Jt(o).types,i=c=>al(c,s),a=new Map(s.map((c,u)=>[c,[Ea(c),n(o.types[u])]]));return c=>{let u=i(c);for(let f=0;fn(o.thunk()));return i=>s()(i)}}}}function o6(e){return t=>t.annotate({toEquivalence:e})}function s6(e){return KP(e.ast)}function i6(e,t){return qI(e.ast,t)}function MN(e,t){let n=qI(sg(e.ast),t);return cF(n,t)}function PN(e){return j(sg(e.ast),{schema:e})}var sg=fa(e=>{let t=c6(e,sg),n=e.context;return t===e||n===void 0?t:Gm(t,ww(n))});function ww(e){return e.constructorDefault===void 0?e:new Er(e.isOptional,e.isMutable,void 0,e.annotations)}function FN(e){if(e.propertySignatures.some(t=>typeof t.name!=\"string\"))throw new globalThis.Error(\"Objects property names must be strings\",{cause:e})}function UN(e){return t=>{let n=new Map;for(let s=0;s{s=nn(s),i=nn(i);let a=e(s),c=e(i);return a!==c?a-c:n.get(s)-n.get(i)});return r.some((s,i)=>s!==t[i])?r:t}}var a6=UN(e=>{switch(e._tag){case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function c6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecJson??e.annotations?.toCodec;if(!un(n))return De(e,[bE]);let r=e.typeParameters.map(s=>Dl(nn(s))),o=n(r);return o===void 0?e:De(e,[Uc(o,t)])}case\"Unknown\":return De(e,[bE]);case\"ObjectKeyword\":return De(e,[s_]);case\"Undefined\":case\"Void\":case\"Literal\":case\"Number\":return e.toCodecJson();case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return FN(e),e.recur(t,Zm);case\"Union\":{let n=a6(e.types);return n!==e.types?new En(n,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}function NN(e){return j(DN(Jt(e.ast)))}var DN=It(e=>{let t=u6(e,DN);return t!==e&&e.context!==void 0?Gm(t,ww(e.context)):t});function u6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecIso??e.annotations?.toCodec;if(un(n)){let r=n(e.typeParameters.map(o=>Dl(o)));return De(e,[Uc(r,t)])}return e}case\"Arrays\":case\"Objects\":case\"Union\":case\"Suspend\":return e.recur(t)}return e}function uu(e){return j(jN(e.ast),{schema:e})}function f6(e){return j(BN(e.ast))}function l6(e,t){let n=Tc(e.ast)??fS(e.ast),r=JF(uu(e));return o=>r(o).pipe($(s=>d6(s,{rootName:n,...t})))}function d6(e,t){let n=t.rootName??\"root\",r=t.arrayItemName??\"item\",o=t.pretty??!0,s=t.indent??\" \",i=t.sortKeys??!0,a=new Set,c=[];return f(n,e,0),c.join(o?`\n`:\"\");function u(d,p){c.push(o?s.repeat(d)+p:p)}function f(d,p,m,g){let{attrs:b,safe:E}=tu.tagInfo(d,g);if(p===void 0)u(m,`<${E}${b}/>`);else if(typeof p==\"string\")u(m,`<${E}${b}>${tu.escapeText(p)}`);else if(typeof p!=\"object\"||p===null)u(m,`<${E}${b}>${tu.escapeText(N(p))}`);else{if(a.has(p))throw new globalThis.Error(\"Cycle detected while serializing to XML.\",{cause:p});a.add(p);try{if(globalThis.globalThis.Array.isArray(p)){if(p.length===0){u(m,`<${E}${b}/>`);return}u(m,`<${E}${b}>`);for(let h of p)f(r,h,m+1);u(m,``);return}let w=p,S=Object.keys(w);if(i&&S.sort(),S.length===0){u(m,`<${E}${b}/>`);return}u(m,`<${E}${b}>`);for(let h of S)f(tu.parseTagName(h).safe,w[h],m+1,h);u(m,``)}finally{a.delete(p)}}}}var tu={escapeText(e){return e.replace(/&/g,\"&\").replace(//g,\">\")},escapeAttribute(e){return e.replace(/&/g,\"&\").replace(/\"/g,\""\").replace(//g,\">\")},parseTagName(e){let t=e,n=e;return/^[A-Za-z_]/.test(n)||(n=\"_\"+n),n=n.replace(/[^A-Za-z0-9._-]/g,\"_\"),/^xml/i.test(n)&&(n=\"_\"+n),{safe:n,changed:n!==t}},tagInfo(e,t){let{changed:n,safe:r}=tu.parseTagName(e),s=n||t&&t!==e?` data-name=\"${tu.escapeAttribute(t??e)}\"`:\"\";return{safe:r,attrs:s}}},p6=UN(e=>{switch(e._tag){case\"Null\":case\"Boolean\":case\"Number\":case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function m6(e,t,n){switch(e._tag){case\"Declaration\":{let r=e.typeParameters.map(u=>j(t(nn(u)))),o=e.annotations?.toCodecStringTree;if(un(o)){let u=o(r);return u===void 0?e:De(e,[Uc(u,t)])}let s=e.annotations?.toCodecJson,i=un(s)?s(r):void 0,a=i===void 0?e.annotations?.toCodec:void 0,c=i??(un(a)?a(r):void 0);return c===void 0?n(e):De(e,[Uc(c,t)])}case\"Null\":return De(e,[h6]);case\"Boolean\":return De(e,[g6]);case\"Unknown\":case\"ObjectKeyword\":return De(e,[SE]);case\"Enum\":case\"Number\":case\"Literal\":case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return FN(e),e.recur(t,Zm);case\"Union\":{let r=p6(e.types);return r!==e.types?new En(r,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}var h6=new Je(new Fn(\"null\"),new Te(ce(()=>null),ce(()=>\"null\"))),g6=new Je(new En([new Fn(\"true\"),new Fn(\"false\")],\"anyOf\"),new Te(ce(e=>e===\"true\"),oa())),LN=new Te(ce(e=>typeof e==\"string\"?[e]:e),Sn()),$N=e=>e.transformation===LN,jN=fa(e=>{let t=m6(e,jN,n=>{throw new globalThis.Error(\"Missing structural codec for StringTree\",{cause:n})});return t!==e&&e.context!==void 0?Gm(t,ww(e.context)):t},{stopAt:$N}),UF=e=>dt(e)?Nc(Jr):Jr,BN=fa(e=>{let t=x6(e);if(eE(t)){let n=Dc(new En([new Kr(t.isMutable,t.elements.map(UF),t.rest.map(UF)),Oo],\"anyOf\"),t,LN);return dt(e)?Nc(n):n}return t},{stopAt:$N});function x6(e){return e._tag===\"Declaration\"||e._tag===\"Arrays\"||e._tag===\"Objects\"||e._tag===\"Union\"||e._tag===\"Suspend\"?e.recur(BN):e}var y6=ue(\"effect/schema/isGreaterThanDate\",J({exclusiveMinimum:er}),({annotations:e,payload:t})=>MU(t.exclusiveMinimum,e)),b6=ue(\"effect/schema/isGreaterThanOrEqualToDate\",J({minimum:er}),({annotations:e,payload:t})=>PU(t.minimum,e)),S6=ue(\"effect/schema/isLessThanDate\",J({exclusiveMaximum:er}),({annotations:e,payload:t})=>FU(t.exclusiveMaximum,e)),E6=ue(\"effect/schema/isLessThanOrEqualToDate\",J({maximum:er}),({annotations:e,payload:t})=>UU(t.maximum,e)),I6=ue(\"effect/schema/isBetweenDate\",J({minimum:er,maximum:er,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>NU(t,e)),w6=ue(\"effect/schema/isGreaterThanBigInt\",J({exclusiveMinimum:Fo}),({annotations:e,payload:t})=>DU(t.exclusiveMinimum,e)),T6=ue(\"effect/schema/isGreaterThanOrEqualToBigInt\",J({minimum:Fo}),({annotations:e,payload:t})=>LU(t.minimum,e)),v6=ue(\"effect/schema/isLessThanBigInt\",J({exclusiveMaximum:Fo}),({annotations:e,payload:t})=>$U(t.exclusiveMaximum,e)),A6=ue(\"effect/schema/isLessThanOrEqualToBigInt\",J({maximum:Fo}),({annotations:e,payload:t})=>jU(t.maximum,e)),C6=ue(\"effect/schema/isBetweenBigInt\",J({minimum:Fo,maximum:Fo,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>BU(t,e));function O6(e){let t=NN(e);return gF(MI(t),RI(t))}function k6(e){return Gh()}function R6(e){return Gh()}function _6(e,t){return n=>j(Ir(n.ast,{toCodecIso:()=>new Je(e.ast,_m(t))}),{schema:n})}function M6(e){let t=PN(e),n=MI(t),r=RI(t);return{empty:[],diff:(o,s)=>fF(n(o),n(s)),combine:(o,s)=>[...o,...s],patch:(o,s)=>{let i=n(o),a=lF(s,i);return Object.is(a,i)?o:r(a)}}}function P6(e){let t=nU(()=>n),n=st([e,Ye(t),Kl(X,t)]);return n}var id=j(Ir(Fc,{toCode:()=>({runtime:\"Schema.Json\",Type:\"Schema.Json\"})})),F6=Kl(X,id),U6=sn(\"effect/schema/Json\",id),N6=J({message:X,name:Ca(X),stack:Ca(X),cause:Ca(id)}),qN=j(Ir(o_,{toCode:()=>({runtime:\"Schema.MutableJson\",Type:\"Schema.MutableJson\"})})),D6=sn(\"effect/schema/MutableJson\",qN);function L6(e){return Co(e.ast)}function $6(e){return e.ast.context?.annotations}var An={};uo(An,{Array:()=>z8,Boolean:()=>x1,ConfigError:()=>hu,FalseValues:()=>g1,LogLevel:()=>b1,Port:()=>y1,Record:()=>q8,TrueValues:()=>h1,all:()=>f1,boolean:()=>X8,date:()=>sY,duration:()=>eY,fail:()=>W8,finite:()=>V8,int:()=>Z8,isConfig:()=>c1,literal:()=>Y8,literals:()=>Q8,logLevel:()=>nY,map:()=>u1,mapOrFail:()=>U8,nested:()=>iY,nonEmptyString:()=>K8,number:()=>G8,option:()=>$8,orElse:()=>N8,port:()=>tY,redacted:()=>rY,schema:()=>vn,string:()=>J8,succeed:()=>H8,unwrap:()=>d1,url:()=>oY,withDefault:()=>l1});var mu={};uo(mu,{ConfigProvider:()=>Pa,SourceError:()=>fg,constantCase:()=>m8,fromDir:()=>R8,fromDotEnv:()=>k8,fromDotEnvContents:()=>n1,fromEnv:()=>t1,fromEnvRecord:()=>Jw,fromUnknown:()=>y8,layer:()=>g8,layerAdd:()=>x8,make:()=>dg,makeArray:()=>Ww,makeRecord:()=>lg,makeValue:()=>pd,mapInput:()=>Hw,nested:()=>h8,orElse:()=>zw});var zN=\"~effect/platform/PlatformError\",fu=class extends yo(\"BadArgument\"){get message(){return`${this.module}.${this.method}${this.description?`: ${this.description}`:\"\"}`}};var WN=class extends yo(\"PlatformError\"){constructor(t){\"cause\"in t?super({reason:t,cause:t.cause}):super({reason:t})}[zN]=zN;get message(){return this.reason.message}};var B6=\"~effect/Stream\",q6={_R:_,_E:_,_A:_},z6={[B6]:q6,pipe(){return K(this,arguments)}},HN=e=>{let t=Object.create(z6);return t.channel=e,t};var H6=\"~effect/Sink\";var J6={_A:_,_In:_,_L:_,_E:_,_R:_},K6={[H6]:J6,pipe(){return K(this,arguments)}};var G6=e=>{let t=Object.create(K6);return t.transform=e,t},JN=e=>Ce((t,n)=>x(v(e.transform(t,n),G)));var KN=e=>G6(t=>{let n=[];if(e<=0)return x([n]);let r;return t.pipe(v(o=>{if(n.length+o.length<=e)return n.push(...o),n.length===e?G():te;for(let s=0;sx([n,r])))});var No={};uo(No,{DefaultChunkSize:()=>sD,Do:()=>_Z,TypeId:()=>oD,accumulate:()=>mZ,aggregate:()=>iZ,aggregateWithin:()=>Uw,bind:()=>PZ,bindEffect:()=>FZ,bindTo:()=>UZ,broadcast:()=>MD,broadcastN:()=>aZ,buffer:()=>gV,bufferArray:()=>xV,callback:()=>cD,catch:()=>ld,catchCause:()=>xD,catchCauseFilter:()=>TV,catchCauseIf:()=>wV,catchFilter:()=>SD,catchIf:()=>bD,catchReason:()=>EV,catchReasons:()=>IV,catchTag:()=>bV,catchTags:()=>SV,changes:()=>hZ,changesWith:()=>PD,changesWithEffect:()=>gZ,chunks:()=>CD,collect:()=>pZ,combine:()=>zV,combineArray:()=>WV,concat:()=>cd,cross:()=>V5,crossWith:()=>hD,debounce:()=>ZV,decodeText:()=>xZ,die:()=>u5,drain:()=>U5,drainFork:()=>N5,drop:()=>Fw,dropRight:()=>jV,dropUntil:()=>DV,dropUntilEffect:()=>LV,dropWhile:()=>vD,dropWhileEffect:()=>AD,dropWhileFilter:()=>$V,empty:()=>ai,encodeText:()=>yZ,ensuring:()=>AZ,fail:()=>ad,failCause:()=>uD,failCauseSync:()=>f5,failSync:()=>c5,filter:()=>cV,filterEffect:()=>fV,filterMap:()=>uV,filterMapEffect:()=>lV,flatMap:()=>pu,flatten:()=>kw,flattenArray:()=>F5,flattenEffect:()=>O5,flattenIterable:()=>q5,flattenTake:()=>z5,forever:()=>B5,fromArray:()=>ud,fromArrayEffect:()=>m5,fromArrays:()=>h5,fromAsyncIterable:()=>y5,fromChannel:()=>O,fromEffect:()=>ig,fromEffectDrain:()=>iD,fromEffectRepeat:()=>aD,fromEffectSchedule:()=>o5,fromEventListener:()=>E5,fromIterable:()=>_a,fromIterableEffect:()=>d5,fromIterableEffectRepeat:()=>p5,fromIteratorSucceed:()=>l5,fromPubSub:()=>g5,fromPubSubTake:()=>fD,fromPull:()=>io,fromQueue:()=>du,fromReadableStream:()=>x5,fromSchedule:()=>b5,fromSubscription:()=>S5,groupAdjacentBy:()=>oZ,groupBy:()=>nZ,groupByKey:()=>rZ,grouped:()=>eZ,groupedWithin:()=>tZ,haltWhen:()=>wZ,ignore:()=>OV,ignoreCause:()=>kV,interleave:()=>EZ,interleaveWith:()=>UD,interruptWhen:()=>IZ,intersperse:()=>FD,intersperseAffixes:()=>SZ,isStream:()=>Ie,iterate:()=>w5,let:()=>MZ,limitBytes:()=>PV,make:()=>i5,map:()=>nr,mapAccum:()=>HV,mapAccumArray:()=>Ma,mapAccumArrayEffect:()=>KV,mapAccumEffect:()=>JV,mapArray:()=>dD,mapArrayEffect:()=>k5,mapBoth:()=>C5,mapEffect:()=>fd,mapError:()=>ED,merge:()=>Rw,mergeAll:()=>G5,mergeEffect:()=>ag,mergeLeft:()=>J5,mergeResult:()=>H5,mergeRight:()=>K5,mkArrayBuffer:()=>JZ,mkString:()=>HZ,mkUint8Array:()=>KZ,never:()=>v5,onEnd:()=>vZ,onError:()=>TZ,onExit:()=>ND,onFirst:()=>LD,onStart:()=>DD,orDie:()=>CV,orElseIfEmpty:()=>vV,orElseSucceed:()=>AV,paginate:()=>I5,partition:()=>pV,partitionEffect:()=>dV,partitionQueue:()=>Pw,peel:()=>hV,pipeThrough:()=>dZ,pipeThroughChannel:()=>fZ,pipeThroughChannelOrFail:()=>lZ,prepend:()=>W5,provide:()=>$D,provideContext:()=>CZ,provideService:()=>OZ,provideServiceEffect:()=>Nw,race:()=>aV,raceAll:()=>gD,range:()=>T5,rechunk:()=>OD,repeat:()=>D5,repeatElements:()=>j5,result:()=>R5,retry:()=>RV,run:()=>BD,runCollect:()=>qD,runCount:()=>NZ,runDrain:()=>ug,runFold:()=>LZ,runFoldEffect:()=>$Z,runForEach:()=>qZ,runForEachArray:()=>zD,runForEachWhile:()=>zZ,runHead:()=>jZ,runIntoPubSub:()=>QZ,runIntoQueue:()=>t8,runLast:()=>BZ,runSum:()=>DZ,scan:()=>GV,scanEffect:()=>VV,schedule:()=>L5,scoped:()=>A5,service:()=>n5,serviceOption:()=>r5,share:()=>uZ,sliding:()=>BV,slidingSize:()=>kD,split:()=>qV,splitLines:()=>bZ,succeed:()=>lu,suspend:()=>tr,switchMap:()=>P5,sync:()=>a5,take:()=>MV,takeRight:()=>FV,takeUntil:()=>ID,takeUntilEffect:()=>wD,takeWhile:()=>TD,takeWhileEffect:()=>NV,takeWhileFilter:()=>UV,tap:()=>pD,tapBoth:()=>_5,tapCause:()=>yV,tapError:()=>yD,tapSink:()=>M5,throttle:()=>XV,throttleEffect:()=>RD,tick:()=>s5,timeout:()=>$5,timeoutOrElse:()=>mD,toAsyncIterable:()=>YZ,toAsyncIterableEffect:()=>ZZ,toAsyncIterableWith:()=>Lw,toChannel:()=>Or,toPubSub:()=>XZ,toPubSubTake:()=>WD,toPull:()=>WZ,toQueue:()=>e8,toReadableStream:()=>GZ,toReadableStreamEffect:()=>VZ,toReadableStreamWith:()=>Dw,transduce:()=>sZ,transformPull:()=>St,transformPullBracket:()=>Ow,unfold:()=>lD,unwrap:()=>ii,updateContext:()=>jD,updateService:()=>kZ,when:()=>mV,withExecutionPlan:()=>_V,withSpan:()=>RZ,zip:()=>Y5,zipFlatten:()=>eV,zipLatest:()=>sV,zipLatestAll:()=>Mw,zipLatestWith:()=>iV,zipLeft:()=>Q5,zipRight:()=>X5,zipWith:()=>_w,zipWithArray:()=>cg,zipWithIndex:()=>tV,zipWithNext:()=>nV,zipWithPrevious:()=>rV,zipWithPreviousAndNext:()=>oV});var VN=\"~effect/RcMap\",V6=e=>({[VN]:VN,lookup:e.lookup,context:e.context,scope:e.scope,idleTimeToLive:e.idleTimeToLive,capacity:e.capacity,state:{_tag:\"Open\",map:pl()},pipe(){return K(this,arguments)}}),ZN=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=V6({lookup:e.lookup,context:n,scope:r,idleTimeToLive:typeof e.idleTimeToLive==\"function\"?wd(e.idleTimeToLive,mt):Le(mt(e.idleTimeToLive??Ms)),capacity:Math.max(e.capacity??Number.POSITIVE_INFINITY,0)});return At(Eo(r,()=>{if(o.state._tag===\"Closed\")return te;let s=o.state.map;return o.state={_tag:\"Closed\"},qs(s,([,i])=>gr(Fe(i.scope,ut))).pipe(bn(()=>oe(()=>{Ym(s)})))}),o)}),Tw=l(2,(e,t)=>Hs(n=>{if(e.state._tag===\"Closed\")return vo;let r=e.state,o=eh(),s=Xs(r.map,t),i;if(s._tag===\"Some\")i=s.value,i.refCount++;else{if(Number.isFinite(e.capacity)&&c_(e.state.map)>=e.capacity)return P(new cb(`RcMap attempted to exceed capacity of ${e.capacity}`));{i={deferred:Bi(),scope:mr(),idleTimeToLive:e.idleTimeToLive(t),finalizer:void 0,fiber:void 0,expiresAt:0,refCount:1},i.finalizer=Z6(e,t,i),hl(r.map,t,i);let c=new Map(e.context.mapUnsafe);o.context.mapUnsafe.forEach((u,f)=>{c.set(f,u)}),c.set(xn.key,i.scope),e.lookup(t).pipe(Ao(ho(c)),la(i.scope)).addObserver(u=>lc(i.deferred,u))}}let a=Pr(o.context,xn);return Ht(a,i.finalizer).pipe(yn(n(qi(i.deferred))))}));var Z6=(e,t,n)=>Gn(r=>{if(n.refCount--,n.refCount>0)return te;if(e.state._tag===\"Closed\"||!a_(e.state.map,t)||Ku(n.idleTimeToLive))return e.state._tag===\"Open\"&&gl(e.state.map,t),Fe(n.scope,ut);if(!Xa(n.idleTimeToLive))return te;let o=r.getRef(Rf);return n.expiresAt=o.currentTimeMillisUnsafe()+_n(n.idleTimeToLive),n.fiber?te:(n.fiber=oS(function s(i){let a=o.currentTimeMillisUnsafe(),c=n.expiresAt-a;return c<=0?e.state._tag===\"Closed\"||n.refCount>0?te:(gl(e.state.map,t),i(Fe(n.scope,ut))):v(o.sleep(go(c)),()=>s(i))}).pipe(Xi(oe(()=>{n.fiber=void 0})),Ao(r.context),la(e.scope)),te)});var vw=l(2,(e,t)=>ea(n=>{if(e.state._tag===\"Closed\")return te;let r=Xs(e.state.map,t);if(r._tag===\"None\"||Ku(r.value.idleTimeToLive))return te;let o=r.value;return o.expiresAt=n.currentTimeMillisUnsafe()+_n(o.idleTimeToLive),te}));var Y6=\"~effect/RcRef\",Aw={_tag:\"Empty\"},Q6={_tag:\"Closed\"},X6={_A:_,_E:_},Cw=class{[Y6]=X6;pipe(){return K(this,arguments)}state=Aw;semaphore=Wc(1);acquire;context;scope;idleTimeToLive;constructor(t,n,r,o){this.acquire=t,this.context=n,this.scope=r,this.idleTimeToLive=o}},QN=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=new Cw(e.acquire,n,r,e.idleTimeToLive?mt(e.idleTimeToLive):void 0);return At(Eo(r,()=>{let s=o.state._tag===\"Acquired\"?Fe(o.state.scope,ut):te;return o.state=Q6,s}),o)}),e5=e=>Hs(function t(n){switch(e.state._tag){case\"Closed\":return vo;case\"Acquired\":return e.state.refCount++,e.state.fiber?At(ei(e.state.fiber),e.state):x(e.state);case\"Empty\":{let r=mr();return e.semaphore.withPermit(z(()=>e.state._tag!==\"Empty\"?t(n):n(xr(e.acquire,Pt(e.context,xn,r))).pipe($(o=>{let s={_tag:\"Acquired\",value:o,scope:r,fiber:void 0,refCount:1,invalidated:!1};return e.state=s,s}),yr(o=>os(o)?Fe(r,o):te))))}}}),XN=xe(function*(e){let t=e,n=yield*e5(t),r=yield*Yi,o=t.idleTimeToLive!==void 0&&Xa(t.idleTimeToLive);return yield*Eo(r,()=>(n.refCount--,n.refCount>0?te:t.idleTimeToLive===void 0?(t.state=Aw,Fe(n.scope,ut)):n.invalidated?Fe(n.scope,ut):o?(n.fiber=Gi(t.idleTimeToLive).pipe(v(()=>t.state._tag===\"Acquired\"&&t.state.refCount===0?(t.state=Aw,Fe(n.scope,ut)):te),Xi(oe(()=>{n.fiber=void 0})),Ao(t.context),la(t.scope)),te):te)),n.value});var eD=QN,tD=XN;var oD=\"~effect/Stream\",Ie=e=>M(e,oD),sD=vl,O=HN,ig=e=>O(Sh($(e,Ee))),n5=e=>ig(eS(e)),r5=e=>ig(tS(e)),iD=e=>io(x(v(e,()=>G()))),aD=e=>io(x($(e,Ee))),o5=(e,t)=>io(en(function*(){let n=yield*Br(t),r=yield*To(e,Ft,Ft.defaultValue()),o=!0,s=z(()=>n(r)).pipe(v(i=>To(e,Ft,i)),$(i=>(r=i,Ee(i))));return z(()=>o?(o=!1,x(Ee(r))):s)})),s5=e=>io(oe(()=>{let t=!0,n=x(Ee(void 0)),r=Gb(n,e);return z(()=>t?(t=!1,n):r)})),io=e=>O(Dt(e)),St=(e,t)=>O(Ce((n,r)=>v(Mo(e.channel,r),o=>t(o,r)))),Ow=(e,t)=>O(eo((n,r,o)=>v(Mo(e.channel,r),s=>t(s,r,o)))),Or=e=>e.channel,cD=(e,t)=>O(U_(e,t)),ai=O(Hc),lu=e=>O(bh(Ee(e))),i5=(...e)=>ud(e),a5=e=>O(D_(()=>Ee(e()))),tr=e=>O(yh(()=>e().channel)),ad=e=>O(_o(e)),c5=e=>O($_(e)),uD=e=>O(Al(e)),u5=e=>O(tI(e)),f5=e=>O(j_(e)),l5=(e,t)=>O(eI(()=>e,t)),_a=(e,t)=>Array.isArray(e)&&t?.chunkSize===void 0?ud(e):O(N_(e,t?.chunkSize)),d5=e=>ii($(e,_a)),p5=e=>pu(aD(e),_a),ud=e=>Me(e)?O(bh(e)):ai,m5=e=>ii($(e,ud)),h5=(...e)=>O(XE(ax(e,Me))),du=e=>O(q_(e)),g5=e=>O(z_(e)),fD=e=>O(W_(e)),x5=e=>O(H_(e)),y5=(e,t)=>O(J_(e,t)),b5=e=>io($(mc(e),t=>Ge($(t(void 0),Ee),()=>G()))),S5=e=>O(rI(e)),E5=(e,t,n)=>cD(r=>{function o(s){v_(r,s)}return Qi(oe(()=>e.addEventListener(t,o,n)),()=>oe(()=>e.removeEventListener(t,o,n)))},{bufferSize:typeof n==\"object\"?n.bufferSize:void 0}),lD=(e,t)=>io(oe(()=>{let n=e;return v(z(()=>t(n)),r=>r===void 0?G():(n=r[1],x(Ee(r[0]))))})),I5=(e,t)=>io(oe(()=>{let n=e,r=!1;return z(function o(){return r?G():v(t(n),([s,i])=>(ae(i)?r=!0:n=i.value,Me(s)?x(s):o()))})})),w5=(e,t)=>lD(e,n=>x([n,t(n)])),T5=(e,t,n=vl)=>e>t?ai:io(oe(()=>{let r=Math.max(1,n),o=e,s=!1;return z(()=>{if(s)return G();let i=t-o+1;if(i>r){let c=nx(o,o+r-1);return o+=r,x(c)}let a=nx(o,o+i-1);return s=!0,x(a)})})),v5=O(L_),ii=e=>O(xs($(e,Or))),A5=e=>O(EM(e.channel)),nr=l(2,(e,t)=>tr(()=>{let n=0;return O(to(e.channel,Mr(r=>t(r,n++))))})),C5=l(2,(e,t)=>e.pipe(nr(t.onSuccess),ED(t.onFailure))),dD=l(2,(e,t)=>O(to(e.channel,t))),fd=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,Eh(t,n),to(Ee),O)),O5=l(e=>Ie(e[0]),(e,t)=>fd(e,_,t)),k5=l(2,(e,t)=>O(Eh(e.channel,t))),R5=e=>e.pipe(nr(se),ld(t=>lu(re(t)))),pD=l(e=>Ie(e[0]),(e,t,n)=>fd(e,r=>At(t(r),r),n)),_5=l(2,(e,t)=>e.pipe(yD(t.onError),pD(t.onElement,{concurrency:t.concurrency}))),M5=l(2,(e,t)=>Ow(e,xe(function*(n,r,o){let s=Yn(),i=Yn(),a,c,u=!1,f=!1,d=s.whenOpen(z(()=>{if(a){let m=a;return a=void 0,f||s.closeUnsafe(),At(i.open,m)}return G()}));yield*z(()=>t.transform(d,o)).pipe(m=>rS(m,g=>(u=!0,os(g)&&(c=g.cause),i.open),!0),yt(o));let p=n.pipe(v(m=>(a=m,i.closeUnsafe(),s.openUnsafe(),At(i.await,m))),Ge(()=>(f=!0,i.closeUnsafe(),s.openUnsafe(),v(i.await,()=>G()))));return z(()=>c?tt(c):u?n:p)}))),pu=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,V_(r=>t(r).channel,n),O)),P5=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,yM(r=>t(r).channel,n),O)),kw=l(e=>Ie(e[0]),(e,t)=>pu(e,_,t)),F5=e=>O(rn(e.channel)),U5=e=>O(Q_(e.channel)),N5=l(2,(e,t)=>ag(e,ug(t))),D5=l(2,(e,t)=>O(X_(e.channel,t))),L5=l(2,(e,t)=>e.channel.pipe(rn,eM(t),to(Ee),O)),$5=l(2,(e,t)=>mD(e,{duration:t,orElse:()=>ai})),mD=l(2,(e,t)=>{let n=mt(t.duration);if(!Xa(n))return e;if(Ku(n))return tr(t.orElse);let r=Symbol();return xD(tr(()=>{let s=eh().getRef(Rf),i=_n(n),a,c=Yn(!1);return Rw(St(e,(u,f)=>z(()=>(a=s.currentTimeMillisUnsafe()+i,c.openUnsafe(),u)).pipe($(d=>(c.closeUnsafe(),a=void 0,d)),x)),iD(en(function*(){for(;;)if(yield*c.await,!(a===void 0||(yield*Gi(a-s.currentTimeMillisUnsafe()),a===void 0)||a-s.currentTimeMillisUnsafe()>0))return yield*hr(r)})),{haltStrategy:\"left\"})}),o=>o.reasons.find(i=>i._tag===\"Die\"&&i.defect===r)?t.orElse():uD(o))}),j5=l(2,(e,t)=>O(Ce((n,r)=>$(pe(rn(e.channel))(n,r),o=>{let s,i=en(function*(){let a=yield*o,c=Ee(a);return s=(yield*mc(t))(a).pipe(At(c),Ge(f=>(s=void 0,i))),c});return z(()=>s??i)})))),B5=e=>O(sI(e.channel)),q5=e=>pu(e,_a),z5=e=>e.channel.pipe(rn,Y_,O),cd=l(2,(e,t)=>kw(ud([e,t]))),W5=l(2,(e,t)=>cd(_a(t),e)),Rw=l(e=>Ie(e[0])&&Ie(e[1]),(e,t,n)=>O(vh(Or(e),Or(t),n))),ag=l(2,(e,t)=>e.channel.pipe(bM(t),O)),H5=l(2,(e,t)=>Rw(nr(e,se),nr(t,re))),J5=l(2,(e,t)=>ag(e,ug(t))),K5=l(2,(e,t)=>ag(t,ug(e))),G5=l(2,(e,t)=>kw(_a(e),t)),V5=l(2,(e,t)=>hD(e,t,(n,r)=>[n,r])),hD=l(3,(e,t,n)=>pu(e,r=>nr(t,o=>n(r,o)))),_w=l(3,(e,t,n)=>cg(e,t,Z5(n))),Z5=e=>(t,n)=>{let r=Math.min(t.length,n.length),o=[];for(let s=0;sO(eo(xe(function*(r,o){let s=yield*Mo(e.channel,o),i=yield*Mo(t.channel,o),a=en(function*(){let f=yield*yt(s,o),d=yield*yt(i,o);return yield*m_([f,d])}),c={_tag:\"PullBoth\"};return en(function*(){let[f,d]=c._tag===\"PullBoth\"?yield*a:c._tag===\"PullLeft\"?[yield*s,c.rightArray]:[c.leftArray,yield*i],p=n(f,d);return Me(p[1])?c={_tag:\"PullRight\",leftArray:p[1]}:Me(p[2])?c={_tag:\"PullLeft\",rightArray:p[2]}:c={_tag:\"PullBoth\"},p[0]})})))),Y5=l(2,(e,t)=>_w(e,t,(n,r)=>[n,r])),Q5=l(2,(e,t)=>cg(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=n.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),X5=l(2,(e,t)=>cg(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=r.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),eV=l(2,(e,t)=>_w(e,t,(n,r)=>[...n,r])),tV=e=>nr(e,(t,n)=>[t,n]),nV=e=>Ma(e,R,(t,n)=>{let r=0;t._tag===\"None\"&&(r=1,t=k(n[0]));let o=gt();for(;rMa(e,R,(t,n)=>{let r=gt();for(let o=0;oMa(e,()=>({prev:R(),current:R()}),(t,n)=>{let r=0,o;t.current._tag===\"None\"?(r=1,o=n[0],t.current=k(o)):o=t.current.value;let s=gt();for(;rO(yh(()=>{let t=[],n=new Set,r=Yn();return Th(XE(e.map((o,s)=>o.channel.pipe(rn,Eh(i=>(t[s]=i,n.has(s)?x(Ee(t.slice())):(n.add(s),n.sizeMw(e,t)),iV=l(3,(e,t,n)=>nr(Mw(e,t),([r,o])=>n(r,o))),gD=(...e)=>O(Ce((t,n)=>oe(()=>{let r,o=Vb(e.map(s=>{let i=vt(n);return Mo(s.channel,i).pipe(v(a=>Jb(x(a),a)),yr(a=>a._tag===\"Success\"?r?Fe(i,a):(r=a.value[0],te):Fe(i,a)),$(([,a])=>a))}));return z(()=>r??o)}))),aV=l(2,(e,t)=>gD(e,t)),cV=l(2,(e,t)=>O(nM(Or(e),t))),uV=l(2,(e,t)=>O(rM(Or(e),t))),fV=l(2,(e,t)=>O(oM(Or(e),t))),lV=l(2,(e,t)=>O(sM(Or(e),t))),Pw=l(e=>Ie(e[0]),xe(function*(e,t,n){let r=yield*Yi,o=yield*Mo(e.channel,r),s=n?.capacity===\"unbounded\"?void 0:n?.capacity??sD,i=yield*wr({capacity:s}),a=yield*wr({capacity:s});return yield*en(function*(){for(;;){let c=yield*o,u=[],f=[];for(let p=0;p0){let p=xh(i,f);p.length>0&&(d=yield*Ec(ma(i,p)))}if(u.length>0){let p=xh(a,u);p.length>0&&(yield*ma(a,p))}d&&(yield*$c(d))}}).pipe(zr(c=>(ti(i,c),ti(a,c),te)),yt(r)),[i,a]})),dV=l(e=>Ie(e[0]),(e,t,n)=>$(Pw(fd(e,r=>t(r),n),r=>r,n),([r,o])=>[du(r),du(o)])),pV=l(e=>Ie(e[0]),(e,t,n)=>$(Pw(e,t,{capacity:n?.bufferSize??16}),([r,o])=>[du(o),du(r)])),mV=l(2,(e,t)=>t.pipe($(n=>n?e:ai),ii)),hV=l(2,xe(function*(e,t){let n,r=yield*dI(e.channel),o=ft(r,a=>(n=a,tt(a))),s=io(x(o)),i=yield*BD(s,t);return n?[i,ai]:(s=io(x(r)),[i,s])})),gV=l(2,(e,t)=>O(wM(e.channel,t))),xV=l(2,(e,t)=>O(IM(e.channel,t))),xD=l(2,(e,t)=>e.channel.pipe(Kc(n=>t(n).channel),O)),yV=l(2,(e,t)=>e.channel.pipe(wh(t),O)),ld=l(2,(e,t)=>O(Qr(e.channel,n=>t(n).channel)));var yD=l(2,(e,t)=>e.channel.pipe(cM(t),O)),bD=l(e=>Ie(e[0]),(e,t,n,r)=>O(uM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),SD=l(e=>Ie(e[0]),(e,t,n,r)=>O(fM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),bV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Array.isArray(t)?s=>M(s,\"_tag\")&&t.includes(s._tag):$t(t);return bD(e,o,n,r)}),SV=l(e=>Ie(e[0]),(e,t,n)=>{let r;return SD(e,o=>(r??=Object.keys(t),M(o,\"_tag\")&&Qp(o._tag)&&r.includes(o._tag)?se(o):re(o)),o=>t[o._tag](o),n)}),EV=l(e=>Ie(e[0]),(e,t,n,r,o)=>O(lM(Or(e),t,n,(s,i)=>r(s,i).channel,o&&((s,i)=>o(s,i).channel)))),IV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Object.create(null);for(let i of Object.keys(n)){let a=n[i];o[i]=(c,u)=>a(c,u).channel}let s=r&&((i,a)=>r(i,a).channel);return O(dM(e.channel,t,o,s))}),ED=l(2,(e,t)=>O(pM(e.channel,t))),wV=l(3,(e,t,n)=>O(aM(e.channel,t,r=>n(r).channel))),TV=l(3,(e,t,n)=>O(iI(e.channel,t,(r,o)=>n(r,o).channel))),vV=l(2,(e,t)=>O(Z_(e.channel,n=>Or(t())))),AV=l(2,(e,t)=>ld(e,n=>lu(t(n)))),CV=e=>O(mM(e.channel)),OV=l(e=>Ie(e[0]),(e,t)=>O(hM(e.channel,t))),kV=l(e=>Ie(e[0]),(e,t)=>O(gM(e.channel,t))),RV=l(2,(e,t)=>O(xM(e.channel,t))),rD=(e,t)=>ii($(Br(t),n=>{let r=Ft.defaultValue(),o=()=>ld(Nw(e,Ft,oe(()=>r)),s=>ii(Ge($(n(s),i=>(r=i,ii(At(Bf,o())))),()=>x(ad(s)))));return o()})),_V=l(e=>Ie(e[0]),(e,t,n)=>tr(()=>{let r=n?.preventFallbackOnPartialStream??!1,o=0,s={attempt:0,stepIndex:0},i=Nw(Gp,oe(()=>(s={attempt:s.attempt+1,stepIndex:o},s))),a=n?.onEvent===void 0?void 0:jb(n.onEvent,()=>s),c,u=a===void 0?_:p=>ND(DD(p,$(a.begin,m=>{c=m})),m=>z(()=>{if(c===void 0)return te;let g=c;return c=void 0,a.end(g,m)})),f=R(),d=tr(()=>{let p=t.steps[o];if(!p)return ad(Xg(f));let m=i(u($D(e,p.provide))),g=!1;if(_e(f)){let b=f.value,E=!1,w=m;m=tr(()=>E?w:(E=!0,ad(b))),m=rD(m,jf(p,!1))}else{let b=jf(p,!0);m=b?rD(m,b):m}return ld(r?LD(m,b=>(g=!0,te)):m,b=>(o++,r&&g?ad(b):(f=k(b),d)))});return d})),MV=l(2,(e,t)=>t<1?ai:ID(e,(n,r)=>r===t-1)),PV=l(3,(e,t,n)=>tr(()=>{let r=BigInt(t),o=BigInt(0),s=!1;return cd(TD(e,i=>{let a=o+BigInt(i.length);return a>r?(s=!0,!1):(o=a,!0)}),tr(()=>s?n():ai))})),FV=l(2,(e,t)=>Ma(e,hs,(n,r)=>(nh(n,r),n.length>t&&El(n,n.length-t),[n,Ss]),{onHalt(n){return pa(n)}})),ID=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return v(z(()=>i?G():r),c=>{let u=c.findIndex(f=>t(f,s++));if(u>=0){i=!0;let f=c.slice(0,n?.excludeLast?u:u+1);return Me(f)?x(f):G()}return x(c)})}))),wD=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return en(function*(){if(i)return yield*G();let a=yield*r;for(let c=0;cSt(e,(n,r)=>oe(()=>{let o=0,s=!1,i=v(z(()=>s?G():n),a=>{let c=[];for(let u=0;uSt(e,(n,r)=>oe(()=>{let o=!1,s=v(z(()=>o?G():n),i=>{let a=[];for(let c=0;cwD(e,(n,r)=>$(t(n,r),o=>!o),{excludeLast:!0})),Fw=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=0,s=n.pipe(v(i=>o>=t?x(i):(o+=i.length,o<=t?s:x(i.slice(t-o)))));return s}))),DV=l(2,(e,t)=>Fw(vD(e,(n,r)=>!t(n,r)),1)),LV=l(2,(e,t)=>Fw(AD(e,(n,r)=>$(t(n,r),o=>!o)),1)),vD=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=v(n,a=>{let c=a.findIndex(u=>!t(u,s++));return c===-1?i:(o=!1,x(a.slice(c)))});return z(()=>o?i:n)}))),$V=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=v(n,i=>{let a=i.findIndex(c=>ie(t(c)));return a===-1?s:(o=!1,x(i.slice(a)))});return z(()=>o?s:n)}))),AD=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=en(function*(){for(;;){let a=yield*n;for(let c=0;co?i:n)}))),jV=l(2,(e,t)=>t<=0?e:St(e,(n,r)=>oe(()=>{let o=hs(),s=v(n,i=>{rh(o,i);let a=o.length-t,c=da(o,a);return qt(c)?x(c):s});return s}))),CD=e=>e.channel.pipe(to(Ee),O),OD=l(2,(e,t)=>(t=Math.max(1,t),St(e,(n,r)=>oe(()=>{let o=gt(),s=0,i,a=!1;return z(function c(){if(a)return G();if(i===void 0)return v(n,u=>o.length===0&&u.length===t?x(u):o.length+u.length{if(o.length===0)return G();let c=o;return a=!0,o=[],x(c)}))})))),BV=l(2,(e,t)=>kD(e,t,1)),kD=l(3,(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=null,i=hs(),a=!1,c=0,u=Vi(r,{onSuccess(f){if(rh(i,f),c>0){let p=i.length;El(i,c),c=Math.max(0,c-p)}if(i.length=t;)if(t===n)d.push(da(i,t));else if(d.push(b_(i,t)),t===1&&n<=0)Zr(i);else{let p=i.length;El(i,n),c=Math.max(0,n-p)}return x(d)},onFailure(f){return a&&El(i,t-n),i.length===0?tt(f):(s=f,x(Ee(pa(i))))}});return z(()=>s?tt(s):u)}))),qV=l(2,(e,t)=>Ma(e,gt,(n,r)=>{let o=gt();for(let s=0;soI(rn(e.channel),rn(t.channel),n,r).pipe(to(Ee),O)),WV=l(4,(e,t,n,r)=>O(oI(e.channel,t.channel,n,r))),HV=l(e=>Ie(e[0]),(e,t,n,r)=>O(Jc(e.channel,t,(o,s)=>{let i=gt();for(let a=0;aIe(e[0]),(e,t,n,r)=>O(Jc(e.channel,t,(o,s)=>{let[i,a]=n(o,s);return o=i,[o,Me(a)?Ee(a):Ss]},r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0))),Ss=gt(),JV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(rn,Jc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):gt()]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),KV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(Jc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):Ss]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),GV=l(3,(e,t,n)=>tr(()=>{let r=!0;return O(Jc(e.channel,Le(t),(o,s)=>{let i=gt();r&&(r=!1,i.push(o));for(let a=0;ae.channel.pipe(rn,iM(t,n),to(Ee),O)),ZV=l(2,(e,t)=>St(e,xe(function*(n,r){let o=yield*Rf,s=_n(mt(t)),i,a,c=1/0,u=Yn(),f=Yn(),d=Yn();yield*n.pipe(u.whenOpen,v(m=>(f.openUnsafe(),i=m,c=o.currentTimeMillisUnsafe()+s,te)),Ct({disableYield:!0}),zr(m=>(a=m,c=o.currentTimeMillisUnsafe(),f.openUnsafe(),d.openUnsafe(),te)),yt(r));let p=z(function m(){let g=o.currentTimeMillisUnsafe(),b=c{if(o.currentTimeMillisUnsafe(){if(a){if(i){let m=x(Ee(Gd(i)));return i=void 0,m}return tt(a)}return u.openUnsafe(),f.whenOpen(p)})}))),RD=l(2,(e,t)=>{let n=t.burst??0;return t.strategy===\"enforce\"?YV(e,t.cost,t.units,t.duration,n):QV(e,t.cost,t.units,t.duration,n)}),YV=(e,t,n,r,o)=>St(e,s=>ea(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(v(s,function d(p){return v(t(p),m=>{let g=i.currentTimeMillisUnsafe(),E=(g-f)/a,w=u+E*n,S=w<0?c:Math.min(w,c);return m<=S?(u=S-m,f=g,x(p)):v(s,d)})}))})),QV=(e,t,n,r,o)=>St(e,s=>ea(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(v(s,d=>v(t(d),p=>{let m=i.currentTimeMillisUnsafe(),b=(m-f)/a,E=u+b*n,S=(E<0?c:Math.min(E,c))-p;if(S>=0)return u=S,f=m,x(d);let h=-S/n,T=Math.max(0,h*a);return T>0?v(Gi(T),()=>(u=S,f=m,x(d))):(u=S,f=m,x(d))})))})),XV=l(2,(e,t)=>RD(e,{...t,cost:n=>x(t.cost(n))})),eZ=l(2,(e,t)=>CD(OD(e,t))),tZ=l(3,(e,t,n)=>Uw(e,KN(t),Db(n))),nZ=l(e=>Ie(e[0]),(e,t,n)=>_D(e,xe(function*(r,o,s){for(let i=0;iIe(e[0]),(e,t,n)=>tr(()=>{let r=pl();return _D(e,xe(function*(o,s,i){for(let a=0;aOw(e,xe(function*(r,o,s){let i=yield*T_();yield*Ht(o,Yr(i));let a=pl(),c=yield*ZN({lookup:u=>Qi(wr({capacity:n?.bufferSize??4096}).pipe(bn(f=>(hl(a,u,f),Qn(i,[u,du(f)])))),f=>(gl(a,u),A_(f))),idleTimeToLive:n?.idleTimeToLive??Fr}).pipe(ss(s));return yield*qr({while:cn,body:Le(v(r,u=>t(u,c,a))),step:Rr}).pipe(ft(u=>Nt(i,u)),yt(o)),zc(i)})),oZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=gt(),a=n.pipe(v(u=>{for(let f=0;fc?G():a),()=>{c=!0;let u=s;return s=void 0,u&&qt(u)?x(Ee([o,u])):G()})}))),sZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=z(()=>{if(s!==void 0){let c=s;return s=void 0,x(c)}return n}).pipe(Ws(c=>(o=rs(c),G()))),a=$(z(()=>t.transform(i,r)),([c,u])=>(s=u,Ee(c)));return z(()=>o||a)}))),iZ=l(2,(e,t)=>Uw(e,t,Nf)),Uw=l(3,(e,t,n)=>O(eo(xe(function*(r,o,s){let i=yield*Mo(e.channel,o),a=Yn(!1),c=Symbol(),u=yield*wr({capacity:0});yield*i.pipe(a.whenOpen,v(h=>(a.closeUnsafe(),Qn(u,h))),Ct,ft(h=>Nt(u,h)),yt(s));let f=R(),d,p=!1,m=yield*mc(n),b=z(function h(){return v(m(f),()=>p?Qn(u,c):h())}).pipe(v(()=>Ki),Ge(()=>G())),E=gs(u).pipe(v(h=>h===c?G():(p=!0,x(h)))),w=z(()=>{if(d!==void 0){let h=d;return d=void 0,p=!0,x(h)}return a.openUnsafe(),E}),S=v(([h,T])=>!p&&u.state._tag===\"Done\"?G():(f=k(h),d=T,x(Ee(h))));return z(()=>u.state._tag===\"Done\"&&d===void 0?u.state.exit:(p=d!==void 0,x(z(()=>t.transform(w,s))))).pipe(v(h=>bc(S(h),b)))})))),aZ=l(2,xe(function*(e,t){let n=yield*cZ(t),r=new Array(t.n),o=yield*xn;for(let s=0;sFe(i,c)),O)}return yield*ga(e.channel,s=>Bc(n,s)).pipe(yr(s=>Bc(n,s)),Ic),r})),cZ=e=>Qi(e.capacity===\"unbounded\"?lh(e):e.strategy===\"dropping\"?uh(e):e.strategy===\"sliding\"?fh(e):ch(e),Il),MD=l(2,(e,t)=>$(WD(e,t),fD)),uZ=l(2,(e,t)=>$(eD({acquire:MD(e,t),idleTimeToLive:t.idleTimeToLive}),n=>ii(tD(n)))),fZ=l(2,(e,t)=>O(aI(e.channel,t))),lZ=l(2,(e,t)=>O(cI(e.channel,t))),dZ=l(2,(e,t)=>e.channel.pipe(cI(JN(t)),Ih(([n,r])=>r?bh(r):Hc),O)),pZ=e=>ig(qD(e)),mZ=e=>Ma(e,gt,(t,n)=>{let r=ox(t,n);return[r,[r]]}),hZ=e=>PD(e,B),PD=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s;return v(n,function i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));uSt(e,(n,r)=>oe(()=>{let o=!0,s;return v(n,xe(function*i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));uIe(e[0]),(e,t)=>tr(()=>{let n=new TextDecoder(t?.encoding);return nr(e,r=>n.decode(r,{stream:!0}))})),yZ=e=>tr(()=>{let t=new TextEncoder;return nr(e,n=>t.encode(n))}),bZ=e=>e.channel.pipe(aI(SM()),O),FD=l(2,(e,t)=>dD(e,(n,r)=>{let o=r===0?[]:[t],s=n.length-1;for(let i=0;ilu(t.start).pipe(cd(FD(e,t.middle)),cd(lu(t.end)))),EZ=l(2,(e,t)=>UD(e,t,_a(tT([!0,!1])))),UD=l(3,(e,t,n)=>O(Ce(xe(function*(r,o){let s=yield*pe(rn(n.channel))(r,o),i=Symbol(),a=!1,c=!1,u=(yield*pe(rn(e.channel))(r,o)).pipe(Ge(()=>(a=!0,x(i)))),f=(yield*pe(rn(t.channel))(r,o)).pipe(Ge(()=>(c=!0,x(i))));return en(function*(){for(;;){if(a&&c)return yield*G();let d=yield*s;if(d&&a||!d&&c)continue;let p=yield*d?u:f;if(p!==i)return Ee(p)}})})))),IZ=l(2,(e,t)=>O(TM(e.channel,t))),wZ=l(2,(e,t)=>O(vM(e.channel,t))),ND=l(2,(e,t)=>O(Cl(e.channel,t))),TZ=l(2,(e,t)=>O(AM(e.channel,t))),DD=l(2,(e,t)=>O(CM(e.channel,t))),LD=l(2,(e,t)=>O(uI(e.channel,n=>t(n[0])))),vZ=l(2,(e,t)=>O(OM(e.channel,t))),AZ=l(2,(e,t)=>O(kM(e.channel,t))),$D=l(e=>Ie(e[0]),(e,t,n)=>O(RM(e.channel,t,n))),CZ=l(2,(e,t)=>O(Ah(e.channel,t))),OZ=l(3,(e,t,n)=>O(Ch(e.channel,t,n))),Nw=l(3,(e,t,n)=>O(Oh(e.channel,t,n))),jD=l(2,(e,t)=>O(_M(e.channel,t))),kZ=l(3,(e,t,n)=>jD(e,r=>Pt(r,t,n(Xe(r,t))))),RZ=function(){let e=Ie(arguments[0]),t=e?arguments[1]:arguments[0],n=Fs(e?arguments[2]:arguments[1]);if(e){let r=arguments[0];return O(fI(r.channel,t,n))}return r=>O(fI(r.channel,t,n))},_Z=lu({}),MZ=l(3,(e,t,n)=>nr(e,r=>({...r,[t]:n(r)})));var PZ=l(e=>Ie(e[0]),(e,t,n,r)=>pu(e,o=>nr(n(o),s=>({...o,[t]:s})),r)),FZ=l(e=>Ie(e[0]),(e,t,n,r)=>fd(e,o=>$(n(o),s=>({...o,[t]:s})),r)),UZ=l(2,(e,t)=>nr(e,n=>({[t]:n}))),BD=l(2,(e,t)=>nS(n=>Mo(e.channel,n).pipe(v(r=>t.transform(r,n)),$(([r])=>r)))),qD=e=>xa(e.channel,()=>[],(t,n)=>{for(let r=0;rxa(e.channel,()=>0,(t,n)=>t+n.length),DZ=e=>xa(e.channel,()=>0,(t,n)=>{for(let r=0;rxa(e.channel,t,(r,o)=>{for(let s=0;sNM(e.channel,t,(r,o)=>{let s=0,i=r;return $(qr({while:()=>sn(i,o[s]),step(a){i=a,s++}}),()=>i)})),jZ=e=>$(FM(e.channel),Bt(aT(0))),BZ=e=>$(UM(e.channel),Bt(Gd)),qZ=l(2,(e,t)=>ga(e.channel,n=>{let r=0;return qr({while:()=>rt(n[r++]),step:Rr})})),zZ=l(2,(e,t)=>PM(e.channel,n=>{let r=!1,o=0;return $(qr({while:()=>!r&&ot(n[o]),step(s){o++,s||(r=!0)}}),()=>!r)})),zD=l(2,(e,t)=>ga(e.channel,t)),ug=e=>MM(e.channel),WZ=e=>dI(e.channel),HZ=e=>xa(e.channel,()=>\"\",(t,n)=>t+n.join(\"\")),JZ=e=>$(lI(e.channel),t=>t.buffer),KZ=e=>lI(e.channel),Dw=l(e=>Ie(e[0]),(e,t,n)=>{let r,o,s=Yn(!1);return new ReadableStream({start(i){o=Wf(xr(zD(e,a=>s.whenOpen(oe(()=>{s.closeUnsafe();for(let c=0;c{a._tag===\"Failure\"?i.error(zp(a.cause)):i.close()})},pull(){return new Promise(i=>{r=i,s.openUnsafe()})},cancel(){if(o)return iS(zs(ei(o)))}},n?.strategy)}),GZ=l(e=>Ie(e[0]),(e,t)=>Dw(e,pn(),t)),VZ=l(e=>Ie(e[0]),(e,t)=>$(Zi(),n=>Dw(e,n,t))),Lw=l(2,(e,t)=>({[Symbol.asyncIterator](){let n=aS(t),r=Ao(t),o=mr(),s,i,a,c,u=d=>{if(c)return c;let p=a;return c=n(At(yn(p?ei(p):te,Fe(o,d)),{done:!0,value:void 0})),c},f=async d=>{try{await u(d)}catch(p){await n(cS(\"Suppressed error while closing Stream async iterator\",p))}};return{async next(){if(c)return c;if(i){let m=i.next();if(!m.done)return m;i=void 0}let d=r(s??v(Mo(e.channel,o),m=>(s=m,m)));a=d;let p=await n(p_(d));if(a===d&&(a=void 0),et(p))return i=p.value[Symbol.iterator](),i.next();if(is(p.cause))return u(ut);if(c&&rb(p.cause))return c;throw await f(p),zp(p.cause)},return(){return u(ut)},async throw(d){throw await f(ub(d)),d}}}})),ZZ=e=>$(Zi(),t=>Lw(e,t)),YZ=e=>Lw(e,pn()),QZ=l(e=>Ie(e[0]),(e,t,n)=>mI(e.channel,t,n)),XZ=l(2,(e,t)=>$M(e.channel,t)),WD=l(2,(e,t)=>jM(e.channel,t)),e8=l(2,(e,t)=>DM(e.channel,t)),t8=l(2,(e,t)=>pI(e.channel,t));var dd=BigInt(1024),Cne=dd*dd*dd*dd*dd;var $w=Vt(\"effect/platform/FileSystem\");var JD=\"~effect/platform/Path\",Bw=Vt(\"effect/Path\");function KD(e,t){let n=\"\",r=0,o=-1,s=0,i;for(let a=0;a<=e.length;++a){if(a2){let c=n.lastIndexOf(\"/\");if(c!==n.length-1){c===-1?(n=\"\",r=0):(n=n.slice(0,c),r=n.length-1-n.lastIndexOf(\"/\")),o=a,s=0;continue}}else if(n.length===2||n.length===1){n=\"\",r=0,o=a,s=0;continue}}t&&(n.length>0?n+=\"/..\":n=\"..\",r=2)}else n.length>0?n+=\"/\"+e.slice(o+1,a):n=e.slice(o+1,a),r=a-o-1;o=a,s=0}else i===46&&s!==-1?++s:s=-1}return n}function n8(e,t){let n=t.dir||t.root,r=t.base||(t.name||\"\")+(t.ext||\"\");return n?n===t.root?n+r:n+e+r:r}function r8(e){if(e.protocol!==\"file:\")return P(new fu({module:\"Path\",method:\"fromFileUrl\",description:\"URL must be of scheme file\"}));if(e.hostname!==\"\")return P(new fu({module:\"Path\",method:\"fromFileUrl\",description:\"Invalid file URL host\"}));let t=e.pathname;for(let n=0;n=-1&&!n;o--){let s;if(o>=0)s=arguments[o];else{let i=globalThis.process;r===void 0&&\"process\"in globalThis&&typeof i==\"object\"&&i!==null&&typeof i.cwd==\"function\"&&(r=i.cwd()),s=r}s.length!==0&&(t=s+\"/\"+t,n=s.charCodeAt(0)===47)}return t=KD(t,!n),n?t.length>0?\"/\"+t:\"/\":t.length>0?t:\".\"},o8=47;function s8(e){let t=new URL(\"file://\"),n=GD(e);return e.charCodeAt(e.length-1)===o8&&n[n.length-1]!==\"/\"&&(n+=\"/\"),t.pathname=l8(n),x(t)}var i8=/%/g,a8=/\\\\/g,c8=/\\n/g,u8=/\\r/g,f8=/\\t/g;function l8(e){return e.includes(\"%\")&&(e=e.replace(i8,\"%25\")),e.includes(\"\\\\\")&&(e=e.replace(a8,\"%5C\")),e.includes(`\n`)&&(e=e.replace(c8,\"%0A\")),e.includes(\"\\r\")&&(e=e.replace(u8,\"%0D\")),e.includes(\"\t\")&&(e=e.replace(f8,\"%09\")),e}var jw=Bw.of({[JD]:JD,resolve:GD,normalize(e){if(e.length===0)return\".\";let t=e.charCodeAt(0)===47,n=e.charCodeAt(e.length-1)===47;return e=KD(e,!t),e.length===0&&!t&&(e=\".\"),e.length>0&&n&&(e+=\"/\"),t?\"/\"+e:e},isAbsolute(e){return e.length>0&&e.charCodeAt(0)===47},join(){if(arguments.length===0)return\".\";let e;for(let t=0;t0&&(e===void 0?e=n:e+=\"/\"+n)}return e===void 0?\".\":jw.normalize(e)},relative(e,t){if(e===t||(e=jw.resolve(e),t=jw.resolve(t),e===t))return\"\";let n=1;for(;nc){if(t.charCodeAt(s+f)===47)return t.slice(s+f+1);if(f===0)return t.slice(s+f)}else o>c&&(e.charCodeAt(n+f)===47?u=f:f===0&&(u=0));break}let p=e.charCodeAt(n+f),m=t.charCodeAt(s+f);if(p!==m)break;p===47&&(u=f)}let d=\"\";for(f=n+u+1;f<=r;++f)(f===r||e.charCodeAt(f)===47)&&(d.length===0?d+=\"..\":d+=\"/..\");return d.length>0?d+t.slice(s+u):(s+=u,t.charCodeAt(s)===47&&++s,t.slice(s))},dirname(e){if(e.length===0)return\".\";let t=e.charCodeAt(0),n=t===47,r=-1,o=!0;for(let s=e.length-1;s>=1;--s)if(t=e.charCodeAt(s),t===47){if(!o){r=s;break}}else o=!1;return r===-1?n?\"/\":\".\":n&&r===1?\"//\":e.slice(0,r)},basename(e,t){let n=0,r=-1,o=!0,s;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return\"\";let i=t.length-1,a=-1;for(s=e.length-1;s>=0;--s){let c=e.charCodeAt(s);if(c===47){if(!o){n=s+1;break}}else a===-1&&(o=!1,a=s+1),i>=0&&(c===t.charCodeAt(i)?--i===-1&&(r=s):(i=-1,r=a))}return n===r?r=a:r===-1&&(r=e.length),e.slice(n,r)}else{for(s=e.length-1;s>=0;--s)if(e.charCodeAt(s)===47){if(!o){n=s+1;break}}else r===-1&&(o=!1,r=s+1);return r===-1?\"\":e.slice(n,r)}},extname(e){let t=-1,n=0,r=-1,o=!0,s=0;for(let i=e.length-1;i>=0;--i){let a=e.charCodeAt(i);if(a===47){if(!o){n=i+1;break}continue}r===-1&&(o=!1,r=i+1),a===46?t===-1?t=i:s!==1&&(s=1):t!==-1&&(s=-1)}return t===-1||r===-1||s===0||s===1&&t===r-1&&t===n+1?\"\":e.slice(t,r)},format:function(t){if(t===null||typeof t!=\"object\")throw new TypeError('The \"pathObject\" argument must be of type Object. Received type '+typeof t);return n8(\"/\",t)},parse(e){let t={root:\"\",dir:\"\",base:\"\",ext:\"\",name:\"\"};if(e.length===0)return t;let n=e.charCodeAt(0),r=n===47,o;r?(t.root=\"/\",o=1):o=0;let s=-1,i=0,a=-1,c=!0,u=e.length-1,f=0;for(;u>=o;--u){if(n=e.charCodeAt(u),n===47){if(!c){i=u+1;break}continue}a===-1&&(c=!1,a=u+1),n===46?s===-1?s=u:f!==1&&(f=1):s!==-1&&(f=-1)}return s===-1||a===-1||f===0||f===1&&s===a-1&&s===i+1?a!==-1&&(i===0&&r?t.base=t.name=e.slice(1,a):t.base=t.name=e.slice(i,a)):(i===0&&r?(t.name=e.slice(1,s),t.base=e.slice(1,a)):(t.name=e.slice(i,s),t.base=e.slice(i,a)),t.ext=e.slice(s,a)),i>0?t.dir=e.slice(0,i-1):r&&(t.dir=\"/\"),t},sep:\"/\",fromFileUrl:r8,toFileUrl:s8,toNamespacedPath:_});function pd(e){return{_tag:\"Value\",value:e}}function lg(e,t){return{_tag:\"Record\",keys:e,value:t}}function Ww(e,t){return{_tag:\"Array\",length:e,value:t}}var fg=class extends yo(\"SourceError\"){},Pa=Ae(\"effect/ConfigProvider\",{defaultValue:()=>t1()}),d8={...ln,toJSON(){return{_id:\"ConfigProvider\"}}},p8=e=>e;function ZD(e,t){let n=Object.create(d8);return n.load=e,n.mapInput=t,n}function YD(e,t){return ZD(n=>e(t(n)),n=>YD(e,wd(t,n)))}function QD(e,t){return ZD(n=>v(e.load(n),r=>r!==void 0?x(r):t.load(n)),n=>QD(e.mapInput(n),t.mapInput(n)))}function dg(e){return YD(e,p8)}var zw=l(2,(e,t)=>QD(e,t)),Hw=l(2,(e,t)=>e.mapInput(t)),m8=Hw(e=>e.map(t=>typeof t==\"number\"?t:PO(t))),h8=l(2,(e,t)=>{let n=typeof t==\"string\"?[t]:t;return Hw(e,r=>[...n,...r])}),g8=e=>wo(e)?hb(Pa)(e):sO(Pa)(e),x8=(e,t)=>hb(Pa)(en(function*(){let n=yield*Pa,r=wo(e)?yield*e:e;return t?.asPrimary?zw(r,n):zw(n,r)}));function y8(e,t){let n=t?.preserveEmptyStrings===!0;return dg(r=>x(b8(e,r,n)))}function b8(e,t,n){let r=e;for(let o of t){if(r==null)return;if(Array.isArray(r)){if(typeof o!=\"number\"||!Number.isInteger(o)||o<0||o>=r.length)return;r=r[o];continue}if(wt(r)){if(typeof o!=\"string\"||!Object.hasOwn(r,o))return;r=r[o];continue}return}return S8(r,n)}function S8(e,t){if(e!=null)return typeof e==\"string\"?XD(e,t):typeof e==\"number\"||typeof e==\"boolean\"||typeof e==\"bigint\"?pd(String(e)):Array.isArray(e)?Ww(e.length):wt(e)?lg(new Set(Object.keys(e))):pd(N(e))}function XD(e,t){let n=e1(e,t);return n===void 0?void 0:pd(n)}function e1(e,t){return e===\"\"&&!t?void 0:e}function Jw(e,t){let n=t?.preserveEmptyStrings===!0,r=E8(e);return dg(o=>x(w8(r,e,o,n)))}function t1(e){let t=e?.env??{...globalThis.process?.env,...import.meta?.env};return Jw(t,{preserveEmptyStrings:e?.preserveEmptyStrings})}function E8(e){let t={};for(let[n,r]of Object.entries(e)){if(r===void 0)continue;let o=n.split(\"_\"),s=t;for(let i of o){let a=s.children??=Object.create(null);s=a[i]??={}}}return t}var I8=/^(0|[1-9][0-9]*)$/;function w8(e,t,n,r){let o=n.map(String).join(\"_\"),s=e1(Object.hasOwn(t,o)?t[o]:void 0,r),i=T8(e,n),a=i?.children?Object.keys(i.children):[];if(a.length===0)return s===void 0?void 0:pd(s);if(a.every(u=>I8.test(u))){let u=Math.max(...a.map(f=>parseInt(f,10)))+1;return Ww(u,s)}return lg(new Set(a),s)}function T8(e,t){if(t.length===0)return e;let n=e;for(let r of t)if(n=n?.children?.[String(r)],!n)return;return n}function n1(e,t){let n=A8(e);return t?.expandVariables&&(n=C8(n)),Jw(n,{preserveEmptyStrings:t?.preserveEmptyStrings})}var v8=/(?:^|^)\\s*(?:export\\s+)?([\\w.-]+)(?:\\s*=\\s*?|:\\s+?)(\\s*'(?:\\\\'|[^'])*'|\\s*\"(?:\\\\\"|[^\"])*\"|\\s*`(?:\\\\`|[^`])*`|[^#\\r\\n]+)?\\s*(?:#.*)?(?:$|$)/mg;function A8(e){let t=Object.create(null);e=e.replace(/\\r\\n?/gm,`\n`);let n;for(;(n=v8.exec(e))!=null;){let r=n[1],o=n[2]||\"\";o=o.trim();let s=o[0];o=o.replace(/^(['\"`])([\\s\\S]*)\\1$/gm,\"$2\"),s==='\"'&&(o=o.replace(/\\\\n/g,`\n`),o=o.replace(/\\\\r/g,\"\\r\")),t[r]=o}return t}function C8(e){let t=Object.create(null);for(let n of Object.keys(e))t[n]=r1(e[n],e).replace(/\\\\\\$/g,\"$\");return t}function r1(e,t){let n=O8(e,/(?!(?<=\\\\))\\$/g);if(n===-1)return e;let r=e.slice(n),o=/((?!(?<=\\\\))\\${?([\\w]+)(?::-([^}\\\\]*))?}?)/,s=r.match(o);if(s!==null){let[i,a,c,u]=s,f=Object.hasOwn(t,c)&&t[c]!==\"\"?t[c]:u??\"\";return r1(e.replace(a,f),t)}return e}function O8(e,t){let n=Array.from(e.matchAll(t));return n.length>0?n.slice(-1)[0].index:-1}var k8=xe(function*(e){let n=yield*(yield*$w).readFileString(e?.path??\".env\");return n1(n,e)}),R8=xe(function*(e){let t=yield*Bw,n=yield*$w,r=e?.rootPath??\"/\",o=e?.preserveEmptyStrings===!0;return dg(s=>{let i=t.join(r,...s.map(String)),a=n.readFileString(i).pipe($(u=>XD(u.trim(),o))),c=n.readDirectory(i).pipe($(u=>lg(new Set(u.map(f=>t.basename(f))))));return a.pipe(Ws(u=>c.pipe(Ws(f=>qw(u)&&qw(f)?x(void 0):P(qw(u)?f:u)))),um(u=>new fg({message:`Failed to read file at ${t.join(r,...s.map(String))}`,cause:u})))})}),qw=e=>e.reason._tag===\"NotFound\";var o1=[\"All\",\"Fatal\",\"Error\",\"Warn\",\"Info\",\"Debug\",\"Trace\",\"None\"];var Kw=\"~effect/Config\",c1=e=>M(e,Kw),hu=class{_tag=\"ConfigError\";name=\"ConfigError\";cause;constructor(t){this.cause=t}get message(){return this.cause.toString()}toString(){return`ConfigError(${this.message})`}},_8={...Qd({label:\"Config\",evaluate(e){return this.parse(e.getRef(Pa))}}),[Kw]:Kw,toJSON(){return{_id:\"Config\"}}};function Do(e){let t=Object.create(_8);return t.evaluator=e,t.parse=n=>e(n,[]).pipe(fs(r=>r.error),lt(r=>r._tag===\"Resolved\"?x(r.value):P(r.error))),t}var Es=(e,t,n)=>e.evaluator(t,n),ci=(e,t)=>({_tag:\"Resolved\",value:e,hasInput:t}),M8=e=>({_tag:\"Absent\",error:e}),Lo=(e,t)=>({error:e,hasInput:t}),P8=e=>$t(e,\"SourceError\"),i1=(e,t)=>e.pipe(qf(n=>P8(n)?P(Lo(new hu(n),t)):hr(n))),F8=(e,t)=>t?e.pipe(fs(n=>Lo(n.error,!0)),lt(n=>n._tag===\"Resolved\"?x(ci(n.value,!0)):P(Lo(n.error,!0)))):e,u1=l(2,(e,t)=>Do((n,r)=>$(Es(e,n,r),o=>o._tag===\"Resolved\"?ci(t(o.value),o.hasInput):o))),U8=l(2,(e,t)=>Do((n,r)=>v(Es(e,n,r),o=>o._tag===\"Resolved\"?t(o.value).pipe(br(s=>ci(s,o.hasInput)),fs(s=>Lo(s,o.hasInput))):x(o)))),N8=l(2,(e,t)=>Do((n,r)=>Xb(Es(e,n,r),{onFailure:o=>F8(Es(t(o.error),n,r),o.hasInput),onSuccess:o=>o._tag===\"Absent\"?Es(t(o.error),n,r):x(o)})));function f1(e){let t=Array.isArray(e)?e:Symbol.iterator in e?[...e]:e;return Array.isArray(t)?Do((n,r)=>lt(rm(t.map(o=>am(Es(o,n,r)))),D8)):Do((n,r)=>lt(rm(zu(t,o=>am(Es(o,n,r)))),L8))}var D8=e=>{let t=[],n,r,o=!1;for(let s of e){if(ie(s)){n??=s.failure,o=o||s.failure.hasInput;continue}let i=s.success;i._tag===\"Absent\"?r??=i:(t.push(i.value),o=o||i.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},L8=e=>{let t={},n,r,o=!1;for(let s in e){let i=e[s];if(ie(i)){n??=i.failure,o=o||i.failure.hasInput;continue}let a=i.success;a._tag===\"Absent\"?r??=a:(F(t,s,a.value),o=o||a.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},l1=l(2,(e,t)=>Do((n,r)=>br(Es(e,n,r),o=>o._tag===\"Absent\"?ci(t,!1):o))),$8=e=>e.pipe(u1(k),l1(R())),d1=e=>c1(e)?e:f1(zu(e,t=>d1(t))),j8=()=>\"\",p1=(e,t)=>e.load(t).pipe(Kb,br(n=>({provider:e,path:t,node:n,toString:j8}))),a1=(e,t)=>p1(e.provider,[...e.path,t]),Gw=e=>e?.value,pg=(e,t)=>Dc(Jr,e,new Te(tn(n=>t(n)),Sn())),m1=e=>{switch(e._tag){case\"Union\":return e.types.every(m1);case\"Objects\":case\"Arrays\":case\"Suspend\":return!1;default:return!0}},Vw=(e,t)=>{switch(e._tag){case\"Objects\":return t?._tag===\"Record\";case\"Arrays\":return t?._tag===\"Array\";case\"Union\":return e.types.some(n=>Vw(n,t));case\"Suspend\":return Vw(e.thunk(),t);default:return Gw(t)!==void 0}},B8=It(e=>{let t=new WeakSet,n=jR(r=>{switch(t.add(r),r._tag){case\"Objects\":{let o=r.indexSignatures.map(i=>Ea(i.parameter)),s=xe(function*(i){if(i.node?._tag!==\"Record\")return;let a=i.node,c=new Set;for(let f of r.propertySignatures)typeof f.name==\"string\"&&c.add(f.name);if(o.length>0)for(let f of a.keys)o.some(d=>d(f))&&c.add(f);let u={};for(let f of c){let d=yield*a1(i,f);d.node!==void 0&&F(u,f,d)}return u});return pg(r.recur(n,i=>i),s)}case\"Arrays\":{let o=xe(function*(s){if(s.node?._tag!==\"Array\")return;let i=[];for(let a=0;ax(Gw(o.node))):r.recur(n);case\"Suspend\":{let o=r.thunk();return t.has(o)||n(o),r.recur(n)}case\"Declaration\":case\"Any\":throw new globalThis.Error(\"Config.schema does not support opaque StringTree encodings\",{cause:r});default:return pg(r,o=>x(Gw(o.node)))}});return n(e)});function vn(e,t){let n=uu(e),r=nn(n.ast),o=Ze(j(B8(n.ast))),s=typeof t==\"string\"?[t]:t??[];return Do((i,a)=>{let c=[...a,...s];return i1(p1(i,c),!1).pipe(lt(u=>{let f=Vw(r,u.node);return i1(o(u).pipe(br(d=>ci(d,f)),wc(d=>{let p=new hu(new bs(c.length>0?new Ve(c,d):d));return f?P(Lo(p,!0)):x(M8(p))})),f)}))})}var h1=so([\"true\",\"yes\",\"on\",\"1\",\"y\"]),g1=so([\"false\",\"no\",\"off\",\"0\",\"n\"]),x1=so([...h1.literals,...g1.literals]).pipe(ne(tg,We({decode:e=>e===\"true\"||e===\"yes\"||e===\"on\"||e===\"1\"||e===\"y\",encode:e=>e?\"true\":\"false\"}))),y1=Uo.check(cu({minimum:1,maximum:65535})),b1=so(o1),q8=(e,t,n)=>{let r=Kl(e,t),o=Gk(n),s=X.pipe(ne(uu(r),{decode:o.decode,encode:Sn({strict:!1}).compose(o.encode)}));return st([r,s])},z8=(e,t)=>{let n=Ye(e),r=t?.separator??\",\",o=X.pipe(ne(uu(n),{decode:Mk(t),encode:Sn({strict:!1}).compose(ce(s=>s.join(r)))}));return st([o,n])};function W8(e){return Do(()=>P(Lo(new hu(e),!1)))}function H8(e){return Do(()=>x(ci(e,!1)))}function J8(e){return vn(X,e)}function K8(e){return vn(dw,e)}function G8(e){return vn(au,e)}function V8(e){return vn(Xn,e)}function Z8(e){return vn(Uo,e)}function Y8(e,t){return vn(ze(e),t)}function Q8(e,t){return vn(so(e),t)}function X8(e){return vn(x1,e)}function eY(e){return vn(mw,e)}function tY(e){return vn(y1,e)}function nY(e){return vn(b1,e)}function rY(e){return vn(ed(X),e)}function oY(e){return vn(td,e)}function sY(e){return vn(er,e)}var iY=l(2,(e,t)=>Do((n,r)=>Es(e,n,[...r,t])));var gu={};uo(gu,{Console:()=>S1,assert:()=>aY,clear:()=>cY,consoleWith:()=>Ot,count:()=>uY,countReset:()=>fY,debug:()=>lY,dir:()=>dY,dirxml:()=>pY,error:()=>mY,group:()=>hY,info:()=>gY,log:()=>xY,table:()=>yY,time:()=>bY,timeLog:()=>SY,trace:()=>EY,warn:()=>IY,withGroup:()=>wY,withTime:()=>TY});var S1=Gy,Ot=e=>Y(t=>e(t.getRef(S1))),aY=(e,...t)=>Ot(n=>D(()=>{n.assert(e,...t)})),cY=Ot(e=>D(()=>{e.clear()})),uY=e=>Ot(t=>D(()=>{t.count(e)})),fY=e=>Ot(t=>D(()=>{t.countReset(e)})),lY=(...e)=>Ot(t=>D(()=>{t.debug(...e)})),dY=(e,t)=>Ot(n=>D(()=>{n.dir(e,t)})),pY=(...e)=>Ot(t=>D(()=>{t.dirxml(...e)})),mY=(...e)=>Ot(t=>D(()=>{t.error(...e)})),hY=e=>Ot(t=>Di(D(()=>{e?.collapsed?t.groupCollapsed(e.label):t.group(e?.label)}),()=>D(()=>{t.groupEnd()}))),gY=(...e)=>Ot(t=>D(()=>{t.info(...e)})),xY=(...e)=>Ot(t=>D(()=>{t.log(...e)})),yY=(e,t)=>Ot(n=>D(()=>{n.table(e,t)})),bY=e=>Ot(t=>Di(D(()=>{t.time(e)}),()=>D(()=>{t.timeEnd(e)}))),SY=(e,...t)=>Ot(n=>D(()=>{n.timeLog(e,...t)})),EY=(...e)=>Ot(t=>D(()=>{t.trace(...e)})),IY=(...e)=>Ot(t=>D(()=>{t.warn(...e)})),wY=l(e=>ee(e[0]),(e,t)=>Ot(n=>xf(D(()=>{t?.collapsed?n.groupCollapsed(t.label):n.group(t?.label)}),()=>e,()=>D(()=>{n.groupEnd()})))),TY=l(e=>ee(e[0]),(e,t)=>Ot(n=>xf(D(()=>{n.time(t)}),()=>e,()=>D(()=>{n.timeEnd(t)}))));var Dn={OK:200,Unauthorized:401,NotFound:404,InternalServerError:500,ServiceUnavailable:503},Zw={[Dn.OK]:\"OK\",[Dn.Unauthorized]:\"Unauthorized\",[Dn.NotFound]:\"Not Found\",[Dn.InternalServerError]:\"Internal Server Error\",[Dn.ServiceUnavailable]:\"Service Unavailable\"},vY=e=>{let t=e.startsWith(\"/\"),n=[];for(let o of e.split(\"/\"))if(!(o===\"\"||o===\".\")){if(o===\"..\"){n.length>0&&n.at(-1)!==\"..\"?n.pop():t||n.push(\"..\");continue}n.push(o)}let r=n.join(\"/\");return t?`/${r}`:r||\".\"},xu=(...e)=>vY(e.filter(Boolean).join(\"/\")),mg=e=>{if(e.length===0)return\".\";let t=e.length;for(;t>1&&e[t-1]===\"/\";)t-=1;let n=e.slice(0,t),r=n.lastIndexOf(\"/\");return r<0?\".\":r===0?\"/\":n.slice(0,r)},E1=e=>{if(!e.startsWith(\"/\"))throw new TypeError(\"Path must be absolute\");let t=new URL(\"file:///\");return t.pathname=e.replace(/%/g,\"%25\").replace(/\\\\/g,\"%5C\"),t};var AY=/^[A-Za-z0-9_-]+$/u,I1=/[*?[{]/u,Yw=(e,t)=>t===e||t.startsWith(`${e.replace(/\\/+$/u,\"\")}/`),yu=class extends bo.TaggedError(\"FunctionFileSystemError\"){},ao=(e,t)=>e.lstat(t).pipe(L.catch(()=>L.undefined)),Fa=(e,t,n)=>L.all([e.realPath(t),e.realPath(n)],{concurrency:2}).pipe(L.map(([r,o])=>Yw(r,o)),L.orElseSucceed(()=>!1)),w1=(e,t,n)=>L.gen(function*(){let r=yield*ao(e,n);if(r===void 0)return!0;if(r.isSymbolicLink||!(yield*Fa(e,t,n)))return!1;if(!r.isDirectory)return!0;let o=yield*e.readDirectory(n).pipe(L.catch(()=>L.undefined));if(o===void 0)return!1;for(let s of o)if(!(yield*w1(e,t,xu(n,s))))return!1;return!0}),md=(e,t)=>t.length===0?\"\":t.startsWith(\"/\")?t:xu(e,t),T1=L.fn(\"Functions.resolveFunctionConfig\")(function*(e){let{root:t,slug:n,overrides:r,fs:o}=e;if(!t.startsWith(\"/\")||!AY.test(n)||n===\"_shared\"||(yield*ao(o,t))===void 0)return;let i=yield*o.realPath(t).pipe(L.orElseSucceed(()=>\"\"));if(!i.startsWith(\"/\"))return;let a=yield*ao(o,i);if(a===void 0||!a.isDirectory)return;let c=e.filesRoot===void 0?i:yield*o.realPath(e.filesRoot).pipe(L.orElseSucceed(()=>\"\"));if(!c.startsWith(\"/\")||!Yw(c,i))return;let u=r.$default,f=r[n],d={...u,...f};if(d.enabled===!1)return;let p=xu(i,n),m=d?.entrypointPath&&d.entrypointPath.length>0?d.entrypointPath:d.entrypoint&&d.entrypoint.length>0?d.entrypoint:\"index.ts\";if(!m.startsWith(\"/\")){let T=yield*ao(o,p);if(T===void 0||!T.isDirectory||!(yield*Fa(o,i,p)))return}let g=md(p,m);if(!(yield*Fa(o,c,g)))return;let b=yield*ao(o,g);if(b===void 0||!b.isFile||b.isSymbolicLink)return;let E=f?.importMapPath??f?.import_map,w=u?.importMapRoot??u?.import_map_root,S=E!==void 0?md(p,E):w!==void 0?md(i,w):md(p,\"\");if(S.length>0){if(!(yield*Fa(o,c,S)))return;let T=yield*ao(o,S);if(T===void 0||!T.isFile||T.isSymbolicLink)return}else for(let T of[\"deno.json\",\"deno.jsonc\"]){let y=xu(p,T),I=yield*ao(o,y);if(I!==void 0){if(!I.isFile||I.isSymbolicLink||!(yield*Fa(o,c,y)))return;S=y;break}}let h=(d.staticFiles??d.static_files??[]).map(T=>md(p,T));for(let T of h){if(!Yw(c,T))return;let y=T.search(I1),I=y<0?T:T.slice(0,y),A=y<0?mg(T):I.slice(0,Math.max(0,I.lastIndexOf(\"/\")))||i;if(!(yield*w1(o,c,A)))return;if(!I1.test(T)){let C=yield*ao(o,T);if(C!==void 0&&(!(yield*Fa(o,c,T))||C.isSymbolicLink))return}}return{entrypointPath:g,importMapPath:S,staticFiles:h,verifyJWT:d.verifyJWT??d.verify_jwt??!0,env:d.env}}),CY=e=>xu(mg(e.entrypointPath),\"package.json\"),v1=e=>{let t=new Map,n=new Map;return(r,o)=>{let s=n.get(r);if(s!==void 0)return s;let i=mg(o.entrypointPath),a=t.get(i),c=a===void 0||a===r?i:e();return a===void 0&&t.set(i,r),n.set(r,c),c}},A1=L.fn(\"Functions.packageJsonContainedFor\")(function*(e){if(!e.root.startsWith(\"/\"))return!1;let t=yield*ao(e.fs,e.root);if(t===void 0||!t.isDirectory&&!t.isSymbolicLink)return!1;let n=yield*e.fs.realPath(e.root).pipe(L.orElseSucceed(()=>\"\"));if(!n.startsWith(\"/\"))return!1;let r=yield*ao(e.fs,n);if(r===void 0||!r.isDirectory)return!1;let o=CY(e.config),s=yield*ao(e.fs,o);return s===void 0||!s.isFile||s.isSymbolicLink?!1:yield*Fa(e.fs,n,o)});var Qw=new TextEncoder,hd=new TextDecoder,hg=new TextDecoder(\"utf-8\",{fatal:!0}),jre=2**32;function C1(...e){let t=e.reduce((o,{length:s})=>o+s,0),n=new Uint8Array(t),r=0;for(let o of e)n.set(o,r),r+=o.length;return n}function bu(e){let t=new Uint8Array(e.length);for(let n=0;n127)throw new TypeError(\"non-ASCII string encountered in encode()\");t[n]=r}return t}var gg=(e,t=\"algorithm.name\")=>new TypeError(`CryptoKey does not support this operation, its ${t} must be ${e}`);function OY(e,t){if(t&&!e.usages.includes(t))throw new TypeError(`CryptoKey does not support this operation, its usages must include ${t}.`)}function O1(e,t){let{modulusLength:n}=t.algorithm;if(typeof n!=\"number\"||n<2048)throw new TypeError(`${e} requires key modulusLength to be 2048 bits or larger`)}function k1(e,t,n){let r=e.algorithm;if(r.name!==t.name)throw gg(t.name);if(t.hash&&r.hash?.name!==t.hash)throw gg(t.hash,\"algorithm.hash\");if(t.namedCurve&&r.namedCurve!==t.namedCurve)throw gg(t.namedCurve,\"algorithm.namedCurve\");if(t.length!==void 0&&r.length!==t.length)throw gg(t.length,\"algorithm.length\");OY(e,n)}function kY(e,t,...n){if(n.length>2){let r=n.pop();e+=`one of type ${n.join(\", \")}, or ${r}.`}else n.length===2?e+=`one of type ${n[0]} or ${n[1]}.`:e+=`of type ${n[0]}.`;return t==null?e+=` Received ${t}`:typeof t==\"function\"&&t.name?e+=` Received function ${t.name}`:typeof t==\"object\"&&t!=null&&t.constructor?.name&&(e+=` Received an instance of ${t.constructor.name}`),e}var gd=(e,t,...n)=>kY(`Key for the ${e} algorithm must be `,t,...n);var an=class extends Error{static code=\"ERR_JOSE_GENERIC\";code=\"ERR_JOSE_GENERIC\";constructor(t,n){super(t,n),this.name=this.constructor.name,Error.captureStackTrace?.(this,this.constructor)}},Is=class extends an{static code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},xd=class extends an{static code=\"ERR_JWT_EXPIRED\";code=\"ERR_JWT_EXPIRED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},xg=class extends an{static code=\"ERR_JOSE_ALG_NOT_ALLOWED\";code=\"ERR_JOSE_ALG_NOT_ALLOWED\"},co=class extends an{static code=\"ERR_JOSE_NOT_SUPPORTED\";code=\"ERR_JOSE_NOT_SUPPORTED\"};var Ln=class extends an{static code=\"ERR_JWS_INVALID\";code=\"ERR_JWS_INVALID\"},Su=class extends an{static code=\"ERR_JWT_INVALID\";code=\"ERR_JWT_INVALID\"};var yd=class extends an{static code=\"ERR_JWKS_INVALID\";code=\"ERR_JWKS_INVALID\"},Eu=class extends an{static code=\"ERR_JWKS_NO_MATCHING_KEY\";code=\"ERR_JWKS_NO_MATCHING_KEY\";constructor(t=\"no applicable key found in the JSON Web Key Set\",n){super(t,n)}},yg=class extends an{[Symbol.asyncIterator]=async function*(){};static code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";constructor(t=\"multiple matching keys found in the JSON Web Key Set\",n){super(t,n)}},bg=class extends an{static code=\"ERR_JWKS_TIMEOUT\";code=\"ERR_JWKS_TIMEOUT\";constructor(t=\"request timed out\",n){super(t,n)}},Sg=class extends an{static code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";constructor(t=\"signature verification failed\",n){super(t,n)}};var Xw=e=>{if(e?.[Symbol.toStringTag]===\"CryptoKey\")return!0;try{return e instanceof CryptoKey}catch{return!1}},RY=e=>e?.[Symbol.toStringTag]===\"KeyObject\",R1=e=>Xw(e)||RY(e);function _1(e){if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);let t=atob(e),n=new Uint8Array(t.length);for(let r=0;rtypeof o!=\"string\")||new Set(r).size!==r.length)throw new TypeError('\"key_ops\" (Key Operations) Parameter must be an array of unique strings');t.key_ops=r}return t}var r0=e=>e[Symbol.toStringTag],_Y=(e,t,n)=>{let{alg:r}=e;if(t.use!==void 0){let o=n===\"sign\"||n===\"verify\"?\"sig\":\"enc\";if(t.use!==o)throw new TypeError(`Invalid key for this operation, its \"use\" must be \"${o}\" when present`)}if(t.alg!==void 0&&t.alg!==r)throw new TypeError(`Invalid key for this operation, its \"alg\" must be \"${r}\" when present`);if(Array.isArray(t.key_ops)){let o=n===\"encrypt\"||n===\"decrypt\"?e.ops?.[n===\"encrypt\"?0:1]:n;if(o&&!t.key_ops.includes(o))throw new TypeError(`Invalid key for this operation, its \"key_ops\" must include \"${o}\" when present`)}};function MY(e,t,n){let{alg:r,secret:o}=e,s=n===\"decrypt\"||n===\"sign\";if(o&&t instanceof Uint8Array)return[U1,t];if(ui(t)){let i=P1(t);if(typeof i.kty!=\"string\")throw new TypeError(o?gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(!(o?i.kty===\"oct\"&&typeof i.k==\"string\":i.kty!==\"oct\"&&(s?i.kty===\"AKP\"&&typeof i.priv==\"string\"||typeof i.d==\"string\":i.d===void 0&&i.priv===void 0)))throw new TypeError(o?'JSON Web Key for symmetric algorithms must have JWK \"kty\" (Key Type) equal to \"oct\" and the JWK \"k\" (Key Value) present':`JSON Web Key for this operation must be a ${s?\"private\":\"public\"} JWK`);return _Y(e,i,n),[L1,t,i]}if(!R1(t))throw new TypeError(o?gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):gd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(o){if(t.type!==\"secret\")throw new TypeError(`${r0(t)} instances for symmetric algorithms must be of type \"secret\"`)}else{if(t.type===\"secret\")throw new TypeError(`${r0(t)} instances for asymmetric algorithms must not be of type \"secret\"`);let i=s?\"private\":\"public\";if((t.type===\"public\"||t.type===\"private\")&&t.type!==i){let a=n===\"sign\"?\"signing\":n===\"verify\"?\"verifying\":`${n.slice(0,-1)}tion`;throw new TypeError(`${r0(t)} instances for asymmetric algorithm ${a} must be of type \"${i}\"`)}}return Xw(t)?[N1,t]:[D1,t]}var U1=0,N1=1,D1=2,L1=3,o0,PY={__proto__:null,prime256v1:\"P-256\",secp384r1:\"P-384\",secp521r1:\"P-521\"};function Tg(e,t,n){o0||=new WeakMap;let r=o0.get(e);return n&&(r?r[t]=n:o0.set(e,{[t]:n})),n??r?.[t]}var F1=async(e,t,n)=>Tg(e,n.alg)??Tg(e,n.alg,await wg(n,{...t,alg:n.alg})),FY=(e,t)=>{let n=Tg(e,t.alg);if(n)return n;let r=e.type===\"public\",o=t.usages[r?0:1],{asymmetricKeyType:s}=e,i=PY[e.asymmetricKeyDetails?.namedCurve],a=t.resolve?.({crv:i,asymmetricKeyType:s})??t.subtle;return Tg(e,t.alg,e.toCryptoKey(a,r,o))};async function $1(e,t,n){let r=MY(e,t,n);switch(r[0]){case U1:case N1:return r[1];case L1:{let o=r[1],s=r[2];if(s.kty===\"oct\")return bd(s.k);if(!Object.isFrozen(o)){let{key_ops:i}=o;Array.isArray(i)&&Object.freeze(i),Object.freeze(o)}return F1(o,s,e)}case D1:{let o=r[1];return o.type===\"secret\"?o.export():\"toCryptoKey\"in o&&typeof o.toCryptoKey==\"function\"?FY(o,e):F1(o,o.export({format:\"jwk\"}),e)}}}function j1(e){let t={__proto__:null};for(let n in e)t[n]={...e[n],alg:n};return t}var B1={__proto__:null,b64:!0};function q1(e,t){if(t!==void 0&&(!Array.isArray(t)||t.some(n=>typeof n!=\"string\")))throw new TypeError(`\"${e}\" option must be an array of strings`);if(t)return new Set(t)}function z1(e,t,n,r,o){if(o.crit!==void 0&&r?.crit===void 0)throw new e('\"crit\" (Critical) Header Parameter MUST be integrity protected');if(!r||r.crit===void 0)return[];if(!Array.isArray(r.crit)||r.crit.length===0||r.crit.some(i=>typeof i!=\"string\"||i.length===0))throw new e('\"crit\" (Critical) Header Parameter MUST be an array of non-empty strings when present');let s=n===void 0?t:{__proto__:null,...n,...t};for(let i of r.crit){if(!(i in s))throw new co(`Extension Header Parameter \"${i}\" is not recognized`);if(!Object.hasOwn(o,i)||o[i]===void 0)throw new e(`Extension Header Parameter \"${i}\" is missing`);if(s[i]&&(!Object.hasOwn(r,i)||r[i]===void 0))throw new e(`Extension Header Parameter \"${i}\" MUST be integrity protected`)}return r.crit}function W1(e,t){if(t.includes(\"b64\")){let n=e.b64;if(typeof n!=\"boolean\")throw new Ln('The \"b64\" (base64url-encode payload) Header Parameter must be a boolean');return n}return!0}async function UY(e,t,n){return t instanceof Uint8Array?crypto.subtle.importKey(\"raw\",t,e.subtle,!1,[n]):(k1(t,e.subtle,n),e.minRsaBits&&O1(e.alg,t),t)}async function H1(e,t,n,r){let o=await UY(e,t,\"verify\");try{return await crypto.subtle.verify(e.signing,o,n,r)}catch{return!1}}var Sd=[[\"verify\"],[\"sign\"]];function s0(e){let t={name:\"HMAC\",hash:`SHA-${e}`};return{kty:[\"oct\"],secret:!0,subtle:t,signing:t,usages:Sd}}function Iu(e,t){let r={name:t?\"RSA-PSS\":\"RSASSA-PKCS1-v1_5\",hash:`SHA-${e}`};return{kty:[\"RSA\"],subtle:r,signing:t?{...r,saltLength:t}:r,usages:Sd,minRsaBits:2048}}function i0(e,t){return{kty:[\"EC\"],crv:e,subtle:{name:\"ECDSA\",namedCurve:e},signing:{name:\"ECDSA\",hash:`SHA-${t}`},usages:Sd}}function J1(){let e={name:\"Ed25519\"};return{kty:[\"OKP\"],crv:\"Ed25519\",subtle:e,signing:e,usages:Sd}}function a0(e){let n={name:`ML-DSA-${e}`};return{kty:[\"AKP\"],subtle:n,signing:n,usages:Sd}}var c0=j1({HS256:s0(256),HS384:s0(384),HS512:s0(512),RS256:Iu(256),RS384:Iu(384),RS512:Iu(512),PS256:Iu(256,32),PS384:Iu(384,48),PS512:Iu(512,64),ES256:i0(\"P-256\",256),ES384:i0(\"P-384\",384),ES512:i0(\"P-521\",512),EdDSA:J1(),Ed25519:J1(),\"ML-DSA-44\":a0(44),\"ML-DSA-65\":a0(65),\"ML-DSA-87\":a0(87)});function K1(e){let t=typeof e==\"string\"?c0[e]:void 0;if(!t)throw new co(`alg ${e} is not supported either by JOSE or your javascript runtime`);return t}function G1(e){return[e&&q1(\"algorithms\",e.algorithms),e?.crit]}function NY(e,t=e===void 0?{}:Ig(e,Ln,\"JWS Protected Header is invalid\")){return t}function DY(e,t,n){let r=W1(e,z1(Ln,B1,n[1],e,t)),o=t.alg;if(typeof o!=\"string\"||!o)throw new Ln('JWS \"alg\" (Algorithm) Header Parameter missing or invalid');if(n[0]&&!n[0].has(o))throw new xg('\"alg\" (Algorithm) Header Parameter value not allowed');return[r,o]}function LY(e){try{return bu(e)}catch{throw new Ln(\"JWS Compact Serialization payload must use only ASCII characters\")}}async function $Y(e,t,n,r,o,s,i){let a=!1;typeof n==\"function\"&&(n=await n(o,e),a=!0);let c=typeof i==\"string\",u=K1(s),f=C1(r!==void 0?bu(r):new Uint8Array,bu(\".\"),c?t[2]??=M1(i,\"payload\",Ln):i),d=t0(e.signature,\"signature\",Ln),p=await $1(u,n,\"verify\");if(!await H1(u,p,d,f))throw new Sg;return[c?t0(i,\"payload\",Ln):i,o,c,p,a]}async function V1(e,t,n){if(e instanceof Uint8Array&&(e=hd.decode(e)),typeof e!=\"string\")throw new Ln(\"Compact JWS must be a string or Uint8Array\");let{0:r,1:o,2:s,length:i}=e.split(\".\");if(i!==3)throw new Ln(\"Invalid Compact JWS\");let a={payload:o,protected:r,signature:s},c=NY(r),[u,f]=DY(c,c,t),d=u?o:LY(o);return $Y(a,t,n,r,c,f,d)}var jY=e=>Math.floor(e.getTime()/1e3),BY={s:1,m:60,h:3600,d:86400,w:604800,y:31557600},qY=/^(\\+|\\-)? ?(\\d+|\\d+\\.\\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i,wu=\"check_failed\";function u0(){throw new TypeError(\"Invalid time period format\")}function Z1(e){typeof e!=\"string\"&&u0();let t=qY.exec(e);(!t||t[4]&&t[1])&&u0();let n=parseFloat(t[2]),r=Math.round(n*BY[t[3][0].toLowerCase()]);return Number.isFinite(r)||u0(),t[1]===\"-\"||t[4]===\"ago\"?-r:r}function f0(e,t){if(!Number.isFinite(t))throw new TypeError(`Invalid ${e} input`);return t}var Y1=e=>{let t=e.toLowerCase();return e.includes(\"/\")?t:`application/${t}`},zY=(e,t)=>typeof e==\"string\"?t.includes(e):Array.isArray(e)?t.some(n=>e.includes(n)):!1;function l0(e,t,n=!1){let r=e[t];if(!(r===void 0&&!n)){if(typeof r!=\"number\")throw new Is(`\"${t}\" claim must be a number`,e,t,\"invalid\");return r}}function d0(e,t){throw new Is(`unexpected \"${t}\" claim value`,e,t,wu)}function Q1(e,t,n={}){let r;try{r=JSON.parse(hg.decode(t))}catch{}if(!ui(r))throw new Su(\"JWT Claims Set must be a top-level JSON object\");let{typ:o}=n;if(o!==void 0&&(typeof e.typ!=\"string\"||Y1(e.typ)!==Y1(o)))throw new Is('unexpected \"typ\" JWT header value',r,\"typ\",wu);let{requiredClaims:s=[],issuer:i,subject:a,audience:c,maxTokenAge:u}=n,f=[...s];u!==void 0&&f.push(\"iat\"),c!==void 0&&f.push(\"aud\"),a!==void 0&&f.push(\"sub\"),i!==void 0&&f.push(\"iss\");for(let S of new Set(f.reverse()))if(!Object.hasOwn(r,S))throw new Is(`missing required \"${S}\" claim`,r,S,\"missing\");i!==void 0&&!(Array.isArray(i)?i:[i]).includes(r.iss)&&d0(r,\"iss\"),a!==void 0&&r.sub!==a&&d0(r,\"sub\"),c!==void 0&&!zY(r.aud,typeof c==\"string\"?[c]:c)&&d0(r,\"aud\");let{clockTolerance:d}=n,p=0;if(typeof d==\"string\")p=Z1(d);else if(d!==void 0){if(typeof d!=\"number\")throw new TypeError(\"Invalid clockTolerance option type\");p=d}f0(\"clockTolerance option\",p);let{currentDate:m}=n,g=f0(\"currentDate option\",jY(m===void 0?new Date:m)),b=l0(r,\"iat\",u!==void 0),E=l0(r,\"nbf\");if(E!==void 0&&E>g+p)throw new Is('\"nbf\" claim timestamp check failed',r,\"nbf\",wu);let w=l0(r,\"exp\");if(w!==void 0&&w<=g-p)throw new xd('\"exp\" claim timestamp check failed',r,\"exp\",wu);if(u!==void 0){let S=g-b,h=f0(\"maxTokenAge option\",typeof u==\"number\"?u:Z1(u));if(S-p>h)throw new xd('\"iat\" claim timestamp check failed (too far in the past)',r,\"iat\",wu);if(S<-p)throw new Is('\"iat\" claim timestamp check failed (it should be in the past)',r,\"iat\",wu)}return r}async function vg(e,t,n){let r=await V1(e,G1(n),t);if(!r[2])throw new Su(\"JWTs MUST NOT use unencoded payload\");let s={payload:Q1(r[1],r[0],n),protectedHeader:r[1]};return typeof t==\"function\"?{...s,key:r[3]}:s}function WY(e,t,n,r){let{kty:o,key_ops:s,ext:i,kid:a,alg:c,use:u,crv:f}=n0(e),d=Array.isArray(s)?[...s]:s;return(i===void 0||typeof i==\"boolean\")&&(d===void 0||Array.isArray(d)&&d.every((p,m)=>typeof p==\"string\"&&d.indexOf(p)===m)&&d.includes(\"verify\"))&&t.kty.includes(o)&&(r===void 0||typeof r==\"string\"&&r===a)&&(c===void 0?o!==\"AKP\":n===c)&&(u===void 0||u===\"sig\")&&(!t.crv||f===t.crv)}async function X1(e,t,n){let r=e.get(t)||e.set(t,{}).get(t),{alg:o}=n;if(r[o]===void 0){let s=await wg(n,{...t,alg:o,ext:!0});if(s.type!==\"public\")throw new yd(\"JSON Web Key Set members must be public keys\");r[o]=s}return r[o]}function Ua(e){let t;try{t=structuredClone(e)}catch{}if(!Eg(t))throw new yd(\"JSON Web Key Set malformed\");let n=new WeakMap;return Object.defineProperty(async(o,s)=>{let{alg:i,kid:a}={...o,...s?.header},c=typeof i==\"string\"?c0[i]:void 0;if(!c||c.secret)throw new co('Unsupported \"alg\" value for a JSON Web Key Set');let u=t.keys.filter(p=>WY(p,c,i,a)),{0:f,length:d}=u;if(!d)throw new Eu;if(d!==1){let p=new yg;throw p[Symbol.asyncIterator]=async function*(){for(let m of u)try{yield await X1(n,m,c)}catch{}},p}return X1(n,f,c)},\"jwks\",{value:()=>structuredClone(t)})}function HY(){return typeof WebSocketPair<\"u\"||typeof navigator<\"u\"&&navigator.userAgent===\"Cloudflare-Workers\"||typeof EdgeRuntime<\"u\"&&EdgeRuntime===\"vercel\"}var p0;(typeof navigator>\"u\"||!navigator.userAgent?.startsWith?.(\"Mozilla/5.0 \"))&&(p0=\"jose/v6.2.10\");var t2=Symbol();async function JY(e,t,n,r=fetch){let o=await r(e,{method:\"GET\",signal:n,redirect:\"manual\",headers:t}).catch(s=>{throw s.name===\"TimeoutError\"?new bg:s});if(o.status!==200)throw new an(\"Expected 200 OK from the JSON Web Key Set HTTP response\");try{return await o.json()}catch{throw new an(\"Failed to parse the JSON Web Key Set HTTP response as JSON\")}}var n2=Symbol();function Ed(e,t){return Number.isFinite(e)&&Date.now(){if(p&&HY()&&(p=void 0),!p){let S=++m,h=p=JY(n,c,AbortSignal.timeout(s),u).then(T=>{let y=Ua(T);if(S<=g)return;b=y;let I=Date.now();f&&(f.uat=I,f.jwks=T),d=I,g=S}).finally(()=>{p===h&&(p=void 0)})}await p};return Object.defineProperties(async(S,h)=>{(!b||!Ed(d,a))&&await E();try{return await b(S,h)}catch(T){if(T instanceof Eu&&!Ed(d,i))return await E(),b(S,h);throw T}},{coolingDown:{get:()=>Ed(d,i),enumerable:!0},fresh:{get:()=>Ed(d,a),enumerable:!0},reload:{value:E,enumerable:!0},reloading:{get:()=>!!p,enumerable:!0},jwks:{value:()=>b?.jwks(),enumerable:!0}})}function h0(e){let t;if(typeof e==\"string\"){let r=e.split(\".\");(r.length===3||r.length===5)&&([t]=r)}else if(typeof e==\"object\"&&e)if(\"protected\"in e)t=e.protected;else throw new TypeError(\"Token does not contain a Protected Header\");let n=\"Invalid Token or Protected Header formatting\";if(typeof t!=\"string\"||!t)throw new TypeError(n);return Ig(t,TypeError,n)}var GY=new Set([\"HOME\",\"HOSTNAME\",\"PATH\",\"PWD\"]),kr=L.runSync(L.gen(function*(){let e=t=>t.pipe(An.option);return{hostPort:yield*e(An.string(\"SUPABASE_INTERNAL_HOST_PORT\")),functionsRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_ROOT\")),filesRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_FILES_ROOT\")),jwtSecret:yield*e(An.string(\"SUPABASE_INTERNAL_JWT_SECRET\")),supabaseUrl:yield*e(An.string(\"SUPABASE_URL\")),wallclock:yield*e(An.string(\"SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC\")),publishableKey:yield*e(An.string(\"SUPABASE_INTERNAL_PUBLISHABLE_KEY\")),secretKey:yield*e(An.string(\"SUPABASE_INTERNAL_SECRET_KEY\")),functionsConfig:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_CONFIG\")),debug:yield*e(An.string(\"SUPABASE_INTERNAL_DEBUG\")),jwks:yield*e(An.string(\"SUPABASE_JWKS\"))}}).pipe(L.provideService(mu.ConfigProvider,mu.fromEnvRecord(Deno.env.toObject(),{preserveEmptyStrings:!0})))),vu=(e,t=\"\")=>ye.getOrElse(e,()=>t),r2=vu(kr.hostPort,\"8081\"),c2=vu(kr.functionsRoot),VY=vu(kr.jwtSecret),ZY=vu(kr.supabaseUrl,\"http://127.0.0.1:54321\"),YY=new URL(\"/auth/v1/.well-known/jwks.json\",ZY),o2=Number.parseInt(vu(kr.wallclock,\"400\"),10),s2=ye.getOrUndefined(kr.publishableKey),i2=ye.getOrUndefined(kr.secretKey),ws={BootError:Dn.ServiceUnavailable,InvalidWorkerResponse:Dn.InternalServerError,WorkerLimit:546},QY={[ws.BootError]:\"BOOT_ERROR\",[ws.InvalidWorkerResponse]:\"WORKER_ERROR\",[ws.WorkerLimit]:\"WORKER_LIMIT\"},XY={[ws.BootError]:\"Worker failed to boot (please check logs)\",[ws.InvalidWorkerResponse]:\"Function exited due to an error (please check logs)\",[ws.WorkerLimit]:\"Worker failed to respond due to a resource limit (please check logs)\"},eQ=new Map([[Deno.errors.InvalidWorkerCreation,ws.BootError],[Deno.errors.InvalidWorkerResponse,ws.InvalidWorkerResponse],[Deno.errors.WorkerRequestCancelled,ws.WorkerLimit]]),tQ=e=>{let t=Deno.errors.WorkerAlreadyRetired;return t!==void 0&&e instanceof t},nQ=(s=>(s.MissingAuthHeader=\"UNAUTHORIZED_NO_AUTH_HEADER\",s.InvalidLegacyJWT=\"UNAUTHORIZED_JWT\",s.InvalidAsymmetricJWT=\"UNAUTHORIZED_ASYMMETRIC_JWT\",s.InvalidTokenFormat=\"UNAUTHORIZED_INVALID_JWT_FORMAT\",s.UnsupportedTokenAlgorithm=\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",s))(nQ||{}),rQ=fe.Struct({enabled:fe.optionalKey(fe.Boolean),verifyJWT:fe.optionalKey(fe.Boolean),verify_jwt:fe.optionalKey(fe.Boolean),entrypointPath:fe.optionalKey(fe.String),entrypoint:fe.optionalKey(fe.String),importMapPath:fe.optionalKey(fe.String),import_map:fe.optionalKey(fe.String),importMapRoot:fe.optionalKey(fe.String),import_map_root:fe.optionalKey(fe.String),staticFiles:fe.optionalKey(fe.Array(fe.String)),static_files:fe.optionalKey(fe.Array(fe.String)),env:fe.optionalKey(fe.Record(fe.String,fe.String))}),oQ=fe.Record(fe.String,rQ),sQ=fe.declare(e=>typeof e==\"object\"&&e!==null&&\"keys\"in e&&Array.isArray(e.keys)&&e.keys.every(t=>typeof t==\"object\"&&t!==null)),iQ=()=>ye.match(kr.functionsConfig,{onNone:()=>({}),onSome:e=>L.runSync(fe.decodeEffect(fe.fromJsonString(oQ))(e).pipe(L.orElseSucceed(()=>({}))))}),g0=iQ();if(ye.getOrUndefined(kr.debug)===\"true\"){let e=Object.fromEntries(Object.entries(g0).map(([t,n])=>[t,Object.fromEntries(Object.entries(n).filter(([r])=>r!==\"env\"))]));L.runSync(gu.log(\"Functions config:\",JSON.stringify(e,null,2)))}var Na=(e,t,n={})=>{let r={...n},o=null;return e!=null&&(typeof e==\"object\"?(r[\"Content-Type\"]=\"application/json\",o=JSON.stringify(e)):(r[\"Content-Type\"]=\"text/plain\",o=typeof e==\"string\"?e:JSON.stringify(e)??null)),new Response(o,{status:t,headers:r})},a2=({code:e,message:t=\"Invalid JWT\"})=>Na({code:e,message:t,msg:t},Dn.Unauthorized,{\"sb-error-code\":e,\"Access-Control-Expose-Headers\":\"sb-error-code\"}),aQ=e=>{for(let[t,n]of eQ.entries())if(t!==void 0&&e instanceof t)return Na({code:QY[n],message:XY[n]},n);return Na({code:Zw[Dn.InternalServerError],message:\"Request failed due to an internal server error\"},Dn.InternalServerError)};function cQ(e){let t=e.split(\" \");return t.length===2&&t[0]===\"Bearer\"?t[1]:null}var uQ=e=>{let t=e.headers.get(\"authorization\"),n=e.headers.get(\"sb-api-key\")?.replace(\"Bearer\",\"\").trim();if(!t&&!n)return{code:\"UNAUTHORIZED_NO_AUTH_HEADER\",message:\"Missing authorization header\"};let r=cQ(t??\"\"),o=!r||r.startsWith(\"sb_\")?n:r;return o||{code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"}},Tu=class extends bo.TaggedError(\"BootstrapOperationError\"){},fQ=L.runSync(ye.match(kr.jwks,{onNone:()=>L.succeed(ye.some(Ua({keys:[]}))),onSome:e=>L.gen(function*(){let t=yield*fe.decodeEffect(fe.fromJsonString(sQ))(e);return yield*L.try({try:()=>Ua(t),catch:n=>new Tu({cause:n})})}).pipe(L.option)})),lQ=ye.getOrElse(fQ,()=>m0(YY)),fi=e=>L.tryPromise({try:e,catch:t=>new Tu({cause:t})}),dQ=e=>L.gen(function*(){return yield*fi(()=>vg(e,lQ)),ye.none()}).pipe(L.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_ASYMMETRIC_JWT\"})));function pQ(e,t){return L.gen(function*(){let n=yield*L.try({try:()=>h0(t).alg,catch:r=>new Tu({cause:r})});return n?n===\"HS256\"?yield*fi(()=>vg(t,new TextEncoder().encode(e))).pipe(L.as(ye.none()),L.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_JWT\"}))):n===\"ES256\"||n===\"RS256\"?yield*dQ(t):ye.some({code:\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",message:`Unsupported JWT algorithm ${n}`}):ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})}).pipe(L.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})))}var u2={lstat:e=>fi(()=>Deno.lstat(e)).pipe(L.mapError(t=>new yu({cause:t})),L.map(t=>({isDirectory:t.isDirectory,isFile:t.isFile,isSymbolicLink:t.isSymlink}))),realPath:e=>fi(()=>Deno.realPath(e)).pipe(L.mapError(t=>new yu({cause:t}))),readDirectory:e=>No.suspend(()=>No.fromAsyncIterable(Deno.readDir(e),t=>new Tu({cause:t})).pipe(No.map(t=>t.name))).pipe(No.runCollect,L.mapError(t=>new yu({cause:t})))},mQ=e=>T1({root:c2,filesRoot:ye.getOrUndefined(kr.filesRoot),slug:e,overrides:g0,fs:u2}),hQ=v1(()=>Deno.makeTempDirSync({prefix:\"supabase-worker-\"})),gQ=e=>e.importMapPath?L.succeed(!1):A1({root:vu(kr.filesRoot,c2),config:e,fs:u2}),f2=e=>No.fromEffectRepeat(fi(()=>e.read()).pipe(L.flatMap(t=>t.done?Wt.done():L.succeed(t.value)))),xQ=e=>e===void 0?L.void:No.runDrain(f2(e)).pipe(L.ignore);function yQ(e,t){let n=new URL(e.url),r=e.headers.get(\"x-forwarded-host\");r&&(n.hostname=r);let o=new Request(n.href,{method:e.method,headers:e.headers,body:t===void 0?null:No.toReadableStream(f2(t)),signal:e.signal,duplex:\"half\"});return o.headers.delete(\"sb-api-key\"),EdgeRuntime.applySupabaseTag(e,o),o}Deno.serve({handler:e=>L.runPromiseExit(L.gen(function*(){let t=yield*L.acquireRelease(L.sync(()=>e.body?.getReader()),u=>L.interruptible(xQ(u))),{pathname:n}=new URL(e.url);if(n===\"/_internal/health\")return Na({message:\"ok\"},Dn.OK);if(n===\"/_internal/metric\")return Response.json(yield*fi(()=>EdgeRuntime.getRuntimeMetrics()));let r=n.split(\"/\")[1];if(!r)return Na(\"Function not found\",Dn.NotFound);let o=yield*mQ(r);if(!o)return Na(\"Function not found\",Dn.NotFound);if(e.method!==\"OPTIONS\"&&o.verifyJWT){let u=uQ(e);if(typeof u!=\"string\")return a2(u);let f=yield*pQ(VY,u);if(ye.isSome(f))return a2(f.value)}let s={...Deno.env.toObject(),...Object.fromEntries(Object.entries(o.env??{}).filter(([u])=>!u.startsWith(\"SUPABASE_\"))),SUPABASE_FUNCTION_SLUG:r};s2&&(s.SUPABASE_PUBLISHABLE_KEYS=yield*fe.encodeEffect(fe.fromJsonString(fe.Unknown))({default:s2})),i2&&(s.SUPABASE_SECRET_KEYS=yield*fe.encodeEffect(fe.fromJsonString(fe.Unknown))({default:i2}));let i=Object.entries(s).filter(([u])=>!GY.has(u)&&!u.startsWith(\"SUPABASE_INTERNAL_\")),a=!(yield*gQ(o));return yield*L.gen(function*(){let u=yield*fi(()=>EdgeRuntime.userWorkers.create({servicePath:hQ(r,o),memoryLimitMb:256,workerTimeoutMs:Number.isFinite(o2)?o2*1e3:4e5,noModuleCache:!0,noNpm:a,envVars:i,forceCreate:!0,customModuleRoot:\"\",cpuTimeSoftLimitMs:1e3,cpuTimeHardLimitMs:2e3,decoratorType:\"tc39\",maybeEntrypoint:E1(o.entrypointPath).href,context:{useReadSyncFileAPI:!0,...o.importMapPath===\"\"?{}:{importMapPath:o.importMapPath}},staticPatterns:o.staticFiles}));return yield*fi(()=>u.fetch(yQ(e,t)))}).pipe(L.retry({times:1,while:({cause:u})=>e.body===null&&tQ(u)}),L.catchTag(\"BootstrapOperationError\",({cause:u})=>gu.error(\"[functions] worker error\",u).pipe(L.andThen(L.succeed(aQ(u))))))}).pipe(L.scoped),{signal:e.signal}).then(t=>{if(Xt.isSuccess(t))return t.value;if(e.signal.aborted&&Wt.hasInterruptsOnly(t.cause))return new Response(null,{status:499});throw Wt.squash(t.cause)}),onListen:()=>{let t=Object.keys(g0).slice(0,5).map(n=>` - http://127.0.0.1:${r2}/functions/v1/${n}`);L.runSync(gu.log(`Serving functions on http://127.0.0.1:${r2}/functions/v1/${t.length?`\n${t.join(`\n`)}`:\"\"}\nUsing ${Deno.version.deno}`))},onError:()=>Na({code:Zw[500],message:\"Request failed due to an internal server error\"},500)});export{nQ as RequestErrors,cQ as extractBearerToken,yQ as prepareUserRequest};\n"; +export const defaultFunctionsBootstrap = "var m2=Object.defineProperty;var uo=(e,t)=>{for(var n in t)m2(e,n,{get:t[n],enumerable:!0})};var K=(e,t)=>{switch(t.length){case 0:return e;case 1:return t[0](e);case 2:return t[1](t[0](e));case 3:return t[2](t[1](t[0](e)));case 4:return t[3](t[2](t[1](t[0](e))));case 5:return t[4](t[3](t[2](t[1](t[0](e)))));case 6:return t[5](t[4](t[3](t[2](t[1](t[0](e))))));case 7:return t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))));case 8:return t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e))))))));case 9:return t[8](t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))))));default:{let n=e;for(let r=0,o=t.length;rt(n,...arguments)};switch(e){case 0:case 1:throw new RangeError(`Invalid arity ${e}`);case 2:return function(n,r){return arguments.length>=2?t(n,r):function(o){return t(o,n)}};case 3:return function(n,r,o){return arguments.length>=3?t(n,r,o):function(s){return t(s,n,r)}};default:return function(){if(arguments.length>=e)return t.apply(this,arguments);let n=arguments;return function(r){return t(r,...n)}}}};var _=e=>e;var Le=e=>()=>e,cn=Le(!0),Cu=Le(!1),Cg=Le(null),fo=Le(void 0),_r=fo;function wd(e,...t){return K(e,t)}function Td(e,t,n,r,o,s,i,a,c){switch(arguments.length){case 1:return e;case 2:return function(){return t(e.apply(this,arguments))};case 3:return function(){return n(t(e.apply(this,arguments)))};case 4:return function(){return r(n(t(e.apply(this,arguments))))};case 5:return function(){return o(r(n(t(e.apply(this,arguments)))))};case 6:return function(){return s(o(r(n(t(e.apply(this,arguments))))))};case 7:return function(){return i(s(o(r(n(t(e.apply(this,arguments)))))))};case 8:return function(){return a(i(s(o(r(n(t(e.apply(this,arguments))))))))};case 9:return function(){return c(a(i(s(o(r(n(t(e.apply(this,arguments)))))))))}}}function It(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),o}}function pi(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),t.set(o,o),o}}var Ou=e=>{let t=new Set(Reflect.ownKeys(e));if(e.constructor===Object)return t;e instanceof Error&&t.delete(\"stack\");let n=Object.getPrototypeOf(e),r=n;for(;r!==null&&r!==Object.prototype;){let o=Reflect.ownKeys(r);for(let s=0;sRu(e)&&t in e),$t=l(2,(e,t)=>M(e,\"_tag\")&&e._tag===t);function w0(e){return e instanceof Error}function $o(e){return M(e,Symbol.iterator)||$n(e)}var be=\"~effect/interfaces/Hash\",H=e=>{switch(typeof e){case\"number\":return On(e);case\"bigint\":return Ue(e.toString(10));case\"boolean\":return Ue(String(e));case\"symbol\":return Ue(String(e));case\"string\":return Ue(e);case\"undefined\":return Ue(\"undefined\");case\"function\":case\"object\":{if(e===null)return Ue(\"null\");if(e instanceof Date)return Number.isNaN(e.getTime())?Ue(\"Invalid Date\"):Ue(e.toISOString());if(e instanceof RegExp)return Ue(e.toString());{if(ja.has(e))return Cd(e);if(_g.has(e))return _g.get(e);let t=b2(e,()=>g2(e)?e[be]():typeof e==\"function\"?Cd(e):e instanceof DataView?Ba(new Uint8Array(e.buffer,e.byteOffset,e.byteLength)):Array.isArray(e)||ArrayBuffer.isView(e)?Ba(e):e instanceof Map?x2(e):e instanceof Set?y2(e):Fg(e));return _g.set(e,t),t}}default:throw new Error(`BUG: unhandled typeof ${typeof e} - please report an issue at https://github.com/Effect-TS/effect/issues`)}},Cd=e=>(Rg.has(e)||Rg.set(e,On(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))),Rg.get(e)),it=l(2,(e,t)=>e*53^t),Ts=e=>e&3221225471|e>>>1&1073741824,g2=e=>M(e,be),On=e=>{if(e!==e)return Ue(\"NaN\");if(e===1/0)return Ue(\"Infinity\");if(e===-1/0)return Ue(\"-Infinity\");let t=e|0;for(t!==e&&(t^=e*4294967295);e>4294967295;)t^=e/=4294967295;return Ts(t)},Ue=e=>{let t=5381,n=e.length;for(;n;)t=t*33^e.charCodeAt(--n);return Ts(t)},Pg=(e,t)=>{let n=12289;for(let r of t)n^=it(H(r),H(e[r]));return Ts(n)},Fg=e=>Pg(e,Ou(e)),Ug=(e,t)=>n=>{let r=e;for(let o of n)r^=t(o);return Ts(r)},Ba=Ug(6151,H),x2=Ug(Ue(\"Map\"),([e,t])=>it(H(e),H(t))),y2=Ug(Ue(\"Set\"),H),Rg=new WeakMap,_g=new WeakMap,Mg=new WeakSet;function b2(e,t){if(Mg.has(e))return Ue(\"[Circular]\");Mg.add(e);let n=t();return Mg.delete(e),n}var Se=\"~effect/interfaces/Equal\";function B(){return arguments.length===1?e=>mi(e,arguments[0]):mi(arguments[0],arguments[1])}function mi(e,t){if(e===t)return!0;if(e==null||t==null)return!1;let n=typeof e;return n!==typeof t?!1:n===\"number\"&&e!==e&&t!==t?!0:n!==\"object\"&&n!==\"function\"||ja.has(e)||ja.has(t)?!1:I2(e,t,E2)}function S2(e,t,n){let r=Dg.has(e),o=Ng.has(t);if(r&&o)return!0;if(r||o)return!1;Dg.add(e),Ng.add(t);let s=n();return Dg.delete(e),Ng.delete(t),s}var Dg=new WeakSet,Ng=new WeakSet;function E2(e,t){if(H(e)!==H(t))return!1;if(e instanceof Date){if(!(t instanceof Date))return!1;let s=e.getTime(),i=t.getTime();return s===i||Number.isNaN(s)&&Number.isNaN(i)}else if(e instanceof RegExp)return t instanceof RegExp?e.toString()===t.toString():!1;let n=v0(e),r=v0(t);if(n!==r)return!1;let o=n&&r;return typeof e==\"function\"&&!o?!1:S2(e,t,()=>{if(o)return e[Se](t);if(Array.isArray(e))return!Array.isArray(t)||e.length!==t.length?!1:w2(e,t);if(ArrayBuffer.isView(e)){let s=e instanceof DataView;if(!ArrayBuffer.isView(t)||e.byteLength!==t.byteLength||s!==t instanceof DataView)return!1;if(s){let i=t;return T0(new Uint8Array(e.buffer,e.byteOffset,e.byteLength),new Uint8Array(i.buffer,i.byteOffset,i.byteLength))}return T0(e,t)}else{if(e instanceof Map)return!(t instanceof Map)||e.size!==t.size?!1:v2(e,t);if(e instanceof Set)return!(t instanceof Set)||e.size!==t.size?!1:A2(e,t)}return T2(e,t)})}function I2(e,t,n){let r=Od.get(e);if(!r)r=new WeakMap,Od.set(e,r);else if(r.has(t))return r.get(t);let o=n(e,t);r.set(t,o);let s=Od.get(t);return s||(s=new WeakMap,Od.set(t,s)),s.set(e,o),o}var Od=new WeakMap;function w2(e,t){for(let n=0;nM(e,Se),Lg=()=>B;var $g=e=>(ja.add(e),e);function or(e,t,n){return{combine:e,initialValue:t,combineAll:n??(r=>{let o=t;for(let s of r)o=e(o,s);return o})}}var Rt=e=>(t,n)=>t===n||e(t,n),C2=(e,t)=>e===t,A0=()=>C2;function C0(e){return Rt((t,n)=>{if(t.length!==n.length)return!1;for(let r=0;re.length>0;var _d=e=>l(3,(t,n,r)=>e(t,o=>({...o,[n]:r(o)}))),Md=e=>l(2,(t,n)=>e(t,r=>({[n]:r}))),Pd=(e,t)=>l(3,(n,r,o)=>t(n,s=>e(o(s),i=>({...s,[r]:i}))));function F(e,t,n){t===\"__proto__\"?Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0}):e[t]=n}function Fd(e,t){for(let n of Reflect.ownKeys(t))Object.prototype.propertyIsEnumerable.call(t,n)&&F(e,n,t[n])}var ye={};uo(ye,{Do:()=>IL,all:()=>fL,andThen:()=>oL,as:()=>J0,asVoid:()=>nL,bind:()=>EL,bindTo:()=>bL,composeK:()=>cL,contains:()=>xL,containsWith:()=>Y0,exists:()=>yL,filter:()=>Ci,filterMap:()=>pL,firstSomeOf:()=>eL,flatMap:()=>vi,flatMapNullishOr:()=>sL,flatten:()=>Ai,fromIterable:()=>G2,fromNullOr:()=>Qg,fromNullishOr:()=>qu,fromUndefinedOr:()=>Yg,gen:()=>wL,getFailure:()=>Z2,getOrElse:()=>Bu,getOrNull:()=>Hd,getOrThrow:()=>ex,getOrThrowWith:()=>Xg,getOrUndefined:()=>ks,getSuccess:()=>V2,isNone:()=>ae,isOption:()=>ec,isSome:()=>_e,let:()=>SL,lift2:()=>hL,liftNullishOr:()=>tL,liftPredicate:()=>gL,liftThrowable:()=>Rs,makeCombinerFailFast:()=>Q0,makeEquivalence:()=>tx,makeOrder:()=>mL,makeReducer:()=>TL,makeReducerFailFast:()=>vL,map:()=>Bt,match:()=>fr,none:()=>R,orElse:()=>Y2,orElseResult:()=>X2,orElseSome:()=>Q2,partitionMap:()=>dL,product:()=>G0,productMany:()=>uL,reduceCompact:()=>lL,some:()=>k,tap:()=>K0,toArray:()=>Z0,toRefinement:()=>K2,void:()=>rL,zipLeft:()=>aL,zipRight:()=>iL,zipWith:()=>V0});function Ud(e){return{combine:e}}var _u=Symbol.for(\"~effect/Redactable\"),k2=e=>M(e,_u);function za(e){return k2(e)?qg(e):e}function qg(e){return e[_u](globalThis[Wa]?.context??R2)}var Wa=\"~effect/Fiber/currentFiber\",k0=new Map,R2={\"~effect/Context\":{},base:k0,depth:0,mapUnsafe:k0,pipe(){return K(this,arguments)}};function N(e,t){let n=t?.space??0,r=new WeakSet,o=n?typeof n==\"number\"?\" \".repeat(n):n:\"\",s=u=>o.repeat(u),i=(u,f)=>{let d=u?.constructor;return d&&d!==Object.prototype.constructor&&d.name?`${d.name}(${f})`:f},a=u=>{try{return Reflect.ownKeys(u)}catch{return[\"[ownKeys threw]\"]}};function c(u,f=0){if(typeof u==\"string\")return JSON.stringify(u);if(typeof u==\"number\"||u==null||typeof u==\"boolean\"||typeof u==\"symbol\")return String(u);if(typeof u==\"bigint\")return String(u)+\"n\";if(typeof u==\"object\"||typeof u==\"function\"){if(r.has(u))return M2;r.add(u);let d;if(_u in u)d=c(qg(u),f);else if(Array.isArray(u))d=!o||u.length<=1?`[${u.map(p=>c(p,f)).join(\",\")}]`:`[\n${s(f+1)}${u.map(p=>c(p,f+1)).join(`,\n`+s(f+1))}\n${s(f)}]`;else if(u instanceof Date)d=zg(u);else if(!t?.ignoreToString&&M(u,\"toString\")&&typeof u.toString==\"function\"&&u.toString!==Object.prototype.toString&&u.toString!==Array.prototype.toString){let p=P2(u);d=u instanceof Error&&u.cause?`${p} (cause: ${c(u.cause,f)})`:p}else if(Symbol.iterator in u)d=`${u.constructor.name}(${c(Array.from(u),f)})`;else{let p=a(u);if(!o||p.length<=1){let m=`{${p.map(g=>`${Bo(g)}:${c(u[g],f)}`).join(\",\")}}`;d=i(u,m)}else{let m=`{\n${p.map(g=>`${s(f+1)}${Bo(g)}: ${c(u[g],f+1)}`).join(`,\n`)}\n${s(f)}}`;d=i(u,m)}}return r.delete(u),d}return String(u)}return c(e,0)}var M2=\"[Circular]\";function Bo(e){return typeof e==\"string\"?JSON.stringify(e):String(e)}function Dd(e){return e.map(t=>`[${Bo(t)}]`).join(\"\")}function zg(e){try{return e.toISOString()}catch{return\"Invalid Date\"}}function P2(e){try{let t=e.toString();return typeof t==\"string\"?t:String(t)}catch{return\"[toString threw]\"}}function hi(e,t){let n=[];return JSON.stringify(e,function(r,o){let s=Object.getOwnPropertyDescriptor(this,r)?.value,i=M(s,_u)?za(s):za(o);if(typeof i==\"bigint\")return N(i);if(typeof i!=\"object\"||i===null)return i;for(;n.length>0&&n[n.length-1]!==this;)n.pop();if(!n.includes(i))return n.push(i),i},t?.space)??\"null\"}var Qe=Symbol.for(\"nodejs.util.inspect.custom\"),ht=e=>{try{return e=za(e),M(e,\"toJSON\")&&un(e.toJSON)&&e.toJSON.length===0?e.toJSON():Array.isArray(e)?e.map(ht):e}catch{return\"[toJSON threw]\"}},_0=(e,t=2)=>{if(typeof e==\"string\")return e;try{return typeof e==\"object\"?hi(e,{space:t}):N(e,{space:t})}catch{return String(e)}},jQ={toJSON(){return ht(this)},[Qe](){return this.toJSON()},toString(){return N(this.toJSON())}},R0=class{[Qe](){return this.toJSON()}toString(){return N(this.toJSON())}};var vs=class e{called=!1;self;constructor(t){this.self=t}next(t){return this.called?{value:t,done:!0}:(this.called=!0,{value:this.self,done:!1})}[Symbol.iterator](){return new e(this.self)}},U2=()=>{let e=\"~effect/Utils/internal\",t={[e]:o=>o()},n={[e]:o=>o()};return t[e](()=>new Error().stack)?.includes(e)===!0?t[e]:n[e]},we=U2();var po=\"~effect/Effect\",gi=\"~effect/Exit\",N2={_A:_,_E:_,_R:_},F0=`${po}/identifier`,Z=`${po}/args`,at=`${po}/evaluate`,fn=`${po}/successCont`,sr=`${po}/failureCont`,As=`${po}/ensureCont`,bi=Symbol.for(\"effect/Effect/Yield\"),ln={pipe(){return K(this,arguments)},toJSON(){return{...this}},toString(){return N(this.toJSON(),{ignoreToString:!0,space:2})},[Qe](){return this.toJSON()}},U0={[be](){return Pg(this,Object.keys(this))},[Se](e){let t=Object.keys(this),n=Object.keys(e);if(t.length!==n.length)return!1;for(let r=0;rM(e,po),Wg=e=>M(e,gi),Ha=\"~effect/Cause\",Ja=\"~effect/Cause/Reason\",Ga=e=>M(e,Ha),D0=e=>M(e,Ja),lo=class{[Ha];reasons;constructor(t){this[Ha]=Ha,this.reasons=t}pipe(){return K(this,arguments)}toJSON(){return{_id:\"Cause\",failures:this.reasons.map(t=>t.toJSON())}}toString(){return`Cause(${N(this.reasons)})`}[Qe](){return this.toJSON()}[Se](t){return Ga(t)&&this.reasons.length===t.reasons.length&&this.reasons.every((n,r)=>B(n,t.reasons[r]))}[be](){return Ba(this.reasons)}},P0=new WeakMap,Ka=class{[Ja];annotations;_tag;constructor(t,n,r){if(this[Ja]=Ja,this._tag=t,n!==Fu&&typeof r==\"object\"&&r!==null&&n.size>0){let o=P0.get(r);o&&(n=new Map([...o,...n])),P0.set(r,n)}this.annotations=n}annotate(t,n){if(t.mapUnsafe.size===0)return this;let r=new Map(this.annotations);t.mapUnsafe.forEach((s,i)=>{n?.overwrite!==!0&&r.has(i)||r.set(i,s)});let o=Object.assign(Object.create(Object.getPrototypeOf(this)),this);return o.annotations=r,o}pipe(){return K(this,arguments)}toString(){return N(this)}[Qe](){return this.toString()}},Fu=new Map,xi=class extends Ka{error;constructor(t,n=Fu){super(\"Fail\",n,t),this.error=t}toString(){return`Fail(${N(this.error)})`}toJSON(){return{_tag:\"Fail\",error:this.error}}[Se](t){return Cs(t)&&B(this.error,t.error)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.error))(H(this.annotations)))}},qo=e=>new lo(e),Nd=new lo([]),Va=e=>new lo([new xi(e)]),Mu=class extends Ka{defect;constructor(t,n=Fu){super(\"Die\",n,t),this.defect=t}toString(){return`Die(${N(this.defect)})`}toJSON(){return{_tag:\"Die\",defect:this.defect}}[Se](t){return Si(t)&&B(this.defect,t.defect)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.defect))(H(this.annotations)))}},Hg=e=>new lo([new Mu(e)]),Za=l(e=>Ga(e[0]),(e,t,n)=>t.mapUnsafe.size===0?e:new lo(e.reasons.map(r=>r.annotate(t,n)))),Cs=e=>e._tag===\"Fail\",Si=e=>e._tag===\"Die\",Ei=e=>e._tag===\"Interrupt\";function $2(e){return ar(\"Effect.evaluate: Not implemented\")}var zo=e=>({...L2,[F0]:e.op,[at]:e[at]??$2,[fn]:e[fn],[sr]:e[sr],[As]:e[As]}),ir=e=>{let t=zo(e);return function(){let n=Object.create(t);return n[Z]=e.single===!1?arguments:arguments[0],n}},N0=e=>{let t={[gi]:gi,_tag:e.op,get[e.prop](){return this[Z]},...zo(e),toString(){return`${e.op}(${N(this[Z])})`},toJSON(){return{_id:\"Exit\",_tag:e.op,[e.prop]:this[Z]}},[Se](n){return Wg(n)&&n._tag===this._tag&&B(this[Z],n[Z])},[be](){return it(Ue(e.op),H(this[Z]))}};return function(n){let r=Object.create(t);return r[Z]=n,r}},Oe=N0({op:\"Success\",prop:\"value\",[at](e){let t=e.getCont(fn);return t?t[fn](this[Z],e,this):e.yieldWith(this)}}),Ld={key:\"effect/Cause/StackTrace\"},Jg={key:\"effect/Cause/InterruptorStackTrace\"},Mt=N0({op:\"Failure\",prop:\"cause\",[at](e){let t=this[Z],n=!1;e.currentStackFrame&&(t=Za(t,{mapUnsafe:new Map([[Ld.key,e.currentStackFrame]])}),n=!0);let r=e.getCont(sr);for(;e.interruptible&&e._interruptedCause&&r;)r=e.getCont(sr);return r?r[sr](t,e,n?void 0:this):e.yieldWith(n?Mt(t):this)}}),jn=e=>Mt(Va(e)),ar=e=>Mt(Hg(e)),Y=ir({op:\"WithFiber\",[at](e){return this[Z](e)}}),j2=(function(){class e extends globalThis.Error{}let t=zo({op:\"YieldableError\",[at](){return jn(this)}});return delete t.toString,Object.assign(e.prototype,t),e})(),Uu=(function(){let e=Symbol.for(\"effect/Data/Error/plainArgs\");return class extends j2{constructor(n){super(n?.message,n?.cause?{cause:n.cause}:void 0),n&&(Fd(this,n),Object.defineProperty(this,e,{value:n,enumerable:!1}))}toJSON(){return{...this[e],...this}}}})(),Wo=e=>{class t extends Uu{_tag=e}return t.prototype.name=e,t},Pu=\"~effect/Cause/NoSuchElementError\",$d=e=>M(e,Pu),Mr=class extends Wo(\"NoSuchElementError\"){[Pu]=Pu;constructor(t){super({message:t})}},yi=\"~effect/Cause/Done\",Du=e=>M(e,yi),L0={[yi]:yi,_tag:\"Done\",value:void 0},Ii=e=>e===void 0?L0:{[yi]:yi,_tag:\"Done\",value:e},B2=jn(L0),$0=e=>e===void 0?B2:jn(Ii(e));var j0=\"~effect/data/Option\",B0={[j0]:{_A:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},q2=Object.defineProperty(Object.assign(Object.create(B0),{_tag:\"Some\",_op:\"Some\",[Se](e){return jd(e)&&Kg(e)&&B(this.value,e.value)},[be](){return it(H(this._tag))(H(this.value))},toString(){return`some(${N(this.value)})`},toJSON(){return{_id:\"Option\",_tag:this._tag,value:ht(this.value)}}}),\"valueOrUndefined\",{get(){return this.value}}),z2=H(\"None\"),W2=Object.assign(Object.create(B0),{_tag:\"None\",_op:\"None\",valueOrUndefined:void 0,[Se](e){return jd(e)&&Bd(e)},[be](){return z2},toString(){return\"none()\"},toJSON(){return{_id:\"Option\",_tag:this._tag}}}),jd=e=>M(e,j0),Bd=e=>e._tag===\"None\",Kg=e=>e._tag===\"Some\",Nu=Object.create(W2),Lu=e=>{let t=Object.create(q2);return t.value=e,t};var z0=\"~effect/data/Result\",W0={[z0]:{_A:e=>e,_E:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},H2=Object.assign(Object.create(W0),{_tag:\"Success\",_op:\"Success\",[Se](e){return qd(e)&&Qa(e)&&B(this.success,e.success)},[be](){return it(H(this._tag))(H(this.success))},toString(){return`success(${N(this.success)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,value:ht(this.success)}}}),J2=Object.assign(Object.create(W0),{_tag:\"Failure\",_op:\"Failure\",[Se](e){return qd(e)&&Ya(e)&&B(this.failure,e.failure)},[be](){return it(H(this._tag))(H(this.failure))},toString(){return`failure(${N(this.failure)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,failure:ht(this.failure)}}}),qd=e=>M(e,z0),Ya=e=>e._tag===\"Failure\",Qa=e=>e._tag===\"Success\",zd=e=>{let t=Object.create(J2);return t.failure=e,t},Wd=e=>{let t=Object.create(H2);return t.success=e,t},Gg=e=>Qa(e)?Nu:Lu(e.failure),Vg=e=>Ya(e)?Nu:Lu(e.success);function Ho(e){return(t,n)=>t===n?0:e(t,n)}var Bn=Ho((e,t)=>globalThis.Number.isNaN(e)&&globalThis.Number.isNaN(t)?0:globalThis.Number.isNaN(e)?-1:globalThis.Number.isNaN(t)?1:eeHo((n,r)=>e(t(n),t(r)))),wi=Zg(Bn,e=>e.getTime());var Xa=e=>l(2,(t,n)=>e(t,n)===-1),Os=e=>l(2,(t,n)=>e(t,n)===1),$u=e=>l(2,(t,n)=>e(t,n)!==1),ju=e=>l(2,(t,n)=>e(t,n)!==-1);var R=()=>Nu,k=Lu,ec=jd,ae=Bd,_e=Kg,fr=l(2,(e,{onNone:t,onSome:n})=>ae(e)?t():n(e.value)),K2=e=>t=>_e(e(t)),G2=e=>{for(let t of e)return k(t);return R()},V2=Vg,Z2=Gg,Bu=l(2,(e,t)=>ae(e)?t():e.value),Y2=l(2,(e,t)=>ae(e)?t():e),Q2=l(2,(e,t)=>ae(e)?k(t()):e),X2=l(2,(e,t)=>ae(e)?Bt(t(),Wd):Bt(e,zd)),eL=e=>{let t=R();for(t of e)if(_e(t))return t;return t},qu=e=>e==null?R():k(e),Yg=e=>e===void 0?R():k(e),Qg=e=>e===null?R():k(e),tL=e=>(...t)=>qu(e(...t)),Hd=Bu(Cg),ks=Bu(fo),Rs=e=>(...t)=>{try{return k(e(...t))}catch{return R()}},Xg=l(2,(e,t)=>{if(_e(e))return e.value;throw t()}),ex=Xg(()=>new Error(\"getOrThrow called on a None\")),Bt=l(2,(e,t)=>ae(e)?R():k(t(e.value))),J0=l(2,(e,t)=>Bt(e,()=>t)),nL=J0(void 0),rL=k(void 0);var vi=l(2,(e,t)=>ae(e)?R():t(e.value)),oL=l(2,(e,t)=>vi(e,n=>{let r=un(t)?t(n):t;return ec(r)?r:k(r)})),sL=l(2,(e,t)=>ae(e)?R():qu(t(e.value))),Ai=vi(_),iL=l(2,(e,t)=>vi(e,()=>t)),aL=l(2,(e,t)=>K0(e,()=>t)),cL=l(2,(e,t)=>n=>vi(e(n),t)),K0=l(2,(e,t)=>vi(e,n=>Bt(t(n),()=>n))),G0=(e,t)=>_e(e)&&_e(t)?k([e.value,t.value]):R(),uL=(e,t)=>{if(ae(e))return R();let n=[e.value];for(let r of t){if(ae(r))return R();n.push(r.value)}return k(n)},fL=e=>{if(Symbol.iterator in e){let n=[];for(let r of e){if(ae(r))return R();n.push(r.value)}return k(n)}let t={};for(let n of Object.keys(e)){let r=e[n];if(ae(r))return R();F(t,n,r.value)}return k(t)},V0=l(3,(e,t,n)=>Bt(G0(e,t),([r,o])=>n(r,o))),lL=l(3,(e,t,n)=>{let r=t;for(let o of e)_e(o)&&(r=n(r,o.value));return r}),Z0=e=>ae(e)?[]:[e.value],dL=l(2,(e,t)=>{if(ae(e))return[R(),R()];let n=t(e.value);return Ya(n)?[k(n.failure),R()]:[R(),k(n.success)]}),pL=l(2,(e,t)=>{if(ae(e))return R();let n=t(e.value);return Qa(n)?k(n.success):R()}),Ci=l(2,(e,t)=>ae(e)?R():t(e.value)?k(e.value):R()),tx=e=>Rt((t,n)=>ae(t)?ae(n):ae(n)?!1:e(t.value,n.value)),mL=e=>Ho((t,n)=>_e(t)?_e(n)?e(t.value,n.value):1:-1),hL=e=>l(2,(t,n)=>V0(t,n,e)),gL=l(2,(e,t)=>t(e)?k(e):R()),Y0=e=>l(2,(t,n)=>ae(t)?!1:e(t.value,n)),xL=Y0(Lg()),yL=l(2,(e,t)=>ae(e)?!1:t(e.value)),bL=Md(Bt),SL=_d(Bt);var EL=Pd(Bt,vi),IL=k({}),wL=(...e)=>{let n=(e.length===1?e[0]:e[1].bind(e[0]))(),r=n.next();for(;!r.done;){let o=r.value;if(ae(o))return o;r=n.next(o.value)}return k(r.value)};function TL(e){return or((t,n)=>ae(t)?n:ae(n)?t:k(e.combine(t.value,n.value)),R())}function Q0(e){return Ud((t,n)=>ae(t)||ae(n)?R():k(e.combine(t.value,n.value)))}function vL(e){let t=Q0(e).combine,n=k(e.initialValue);return or(t,n,r=>{let o=n;for(let s of r)if(o=t(o,s),ae(o))return o;return o})}var se=Wd,re=zd;var X0=e=>{if(un(e))try{return se(e())}catch(t){return re(t)}else try{return se(e.try())}catch(t){return re(e.catch(t))}};var eT=qd,ie=Ya,Tt=Qa;var tT=(e,t)=>Rt((n,r)=>ie(n)?ie(r)&&t(n.failure,r.failure):Tt(r)&&e(n.success,r.success));var Jd=l(2,(e,t)=>ie(e)?re(t(e.failure)):e),Oi=l(2,(e,t)=>Tt(e)?se(t(e.success)):e),lr=l(2,(e,{onFailure:t,onSuccess:n})=>ie(e)?t(e.failure):n(e.success));var zu=l(2,(e,t)=>ie(e)?t(e.failure):e.success);var Kd=l(2,(e,t)=>ie(e)?re(e.failure):t(e.success));var AL=(e,t)=>{let n=t?.length!==void 0?Math.max(1,Math.floor(t.length)):1/0;return{[Symbol.iterator](){let r=0;return{next(){return rOL(AL(()=>e,{length:t}))),nT=e=>CL(e,1/0);var rT=e=>{let n=e[Symbol.iterator]().next();if(n.done)throw new Error(\"headUnsafe: empty iterable\");return n.value};var OL=e=>({[Symbol.iterator](){let t=e[Symbol.iterator](),n;function r(){for(;;){if(n===void 0){let s=t.next();if(s.done)return s;n=s.value[Symbol.iterator]()}let o=n.next();if(!o.done)return o;n=void 0}}return{next:r}}});var oT=l(2,(e,t)=>({[Symbol.iterator](){let n=e[Symbol.iterator](),r=0;return{next(){let o=n.next();for(;!o.done;){if(t(o.value,r++))return{done:!1,value:o.value};o=n.next()}return{done:!0,value:void 0}}}}}));var Wu=l(2,(e,t)=>{let n={...e};for(let r of kL(e))F(n,r,t(e[r],r));return n});var kL=e=>Object.keys(e);var Hu=globalThis.Array;var sT=l(2,(e,t)=>{let n=Math.max(1,Math.floor(e)),r=new Hu(n);for(let o=0;oe<=t?sT(t-e+1,n=>e+n):[e];var Be=e=>Hu.isArray(e)?e:Hu.from(e),iT=e=>Hu.isArray(e)?e:[e];var ox=l(2,(e,t)=>[...e,t]),sx=l(2,(e,t)=>Be(e).concat(Be(t)));var aT=Hu.isArray;var qt=jg,Me=jg;function RL(e,t){return!Number.isFinite(e)||e<0||e>=t.length}var cT=l(2,(e,t)=>{let n=Math.floor(t);if(RL(n,e))throw new Error(`Index out of bounds: ${n}`);return e[n]});var Vd=e=>e[e.length-1];var uT=l(2,(e,t)=>{let n=0,r=[];for(let o of e){if(!t(o,n))break;r.push(o),n++}return r});var _L=(e,t)=>{let n=H(t),r=e.get(n);if(r===void 0)return e.set(n,[t]),!0;for(let o of r)if(B(o,t))return!1;return r.push(t),!0};var ix=l(2,(e,t)=>{let n=Be(e),r=Be(t);return Me(n)?Me(r)?Zd(sx(n,r)):n:r});var gt=()=>[],Ee=e=>[e],Pr=l(2,(e,t)=>e.map(t));var ax=e=>{let t=[];for(let n of e)_e(n)&&t.push(n.value);return t};var cx=l(2,(e,t)=>{let n=Be(e),r=[];for(let o=0;o{let n=[],r=[],o=0;for(let s of e){let i=t(s,o++);Tt(i)?r.push(i.success):n.push(i.failure)}return[n,r]});var Zd=e=>{let t=Be(e);if(t.length<2)return[...t];let n=new Map,r=[];for(let o of t)_L(n,o)&&r.push(o);return r};var ML=or((e,t)=>e.concat(t),[]);function fT(){return ML}var lT=/^[+-]?\\d+$/,fx=\"~effect/BigDecimal\",NL={[fx]:fx,[be](){let e=lx(this);return it(H(e.value),On(e.scale))},[Se](e){return Qd(e)&&zL(this,e)},toString(){return`BigDecimal(${mo(this)})`},toJSON(){return{_id:\"BigDecimal\",value:String(this.value),scale:this.scale}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},Qd=e=>M(e,fx),Rn=(e,t)=>{let n=Object.create(NL);return n.value=e,n.scale=t,n},pT=(e,t)=>{if(e!==qn&&e%Yd===qn)throw new RangeError(\"Value must be normalized\");let n=Rn(e,t);return n.normalized=n,n},qn=BigInt(0),LL=BigInt(1),$L=BigInt(-1);var Yd=BigInt(10),mT=pT(qn,0);var lx=e=>{if(e.normalized===void 0)if(e.value===qn)e.normalized=mT;else{let t=`${e.value}`,n=0;for(let s=t.length-1;s>=0&&t[s]===\"0\";s--)n++;n===0&&(e.normalized=e);let r=BigInt(t.substring(0,t.length-n)),o=e.scale-n;e.normalized=pT(r,o)}return e.normalized},_s=l(2,(e,t)=>t>e.scale?Rn(e.value*Yd**BigInt(t-e.scale),t):tt.value===qn?e:e.value===qn?t:e.scale>t.scale?Rn(_s(t,e.scale).value+e.value,e.scale):e.scale{let n=Bn(dT(e),dT(t));return n!==0?n:e.scale>t.scale?ur(e.value,_s(t,e.scale).value):e.scalee.value===qn?0:e.valuee.valuee.scale>t.scale?_s(t,e.scale).value===e.value:e.scaledx(e,t));var gT=e=>{if(e===\"\")return k(mT);let t,n,r=e.search(/[eE]/);if(r!==-1){let c=e.slice(r+1);if(t=e.slice(0,r),n=Number(c),t===\"\"||!Number.isSafeInteger(n)||!lT.test(c))return R()}else t=e,n=0;let o,s,i=t.search(/\\./);if(i!==-1){let c=t.slice(0,i),u=t.slice(i+1);o=`${c}${u}`,s=u.length}else o=t,s=0;if(!lT.test(o))return R();let a=s-n;return Number.isSafeInteger(a)?k(Rn(BigInt(o),a)):R()};var mo=e=>{let t=lx(e);if(Math.abs(t.scale)>=16)return WL(t);let n=t.value=r.length)o=\"0\",s=\"0\".repeat(t.scale-r.length)+r;else{let a=r.length-t.scale;if(a>r.length){let c=a-r.length;o=`${r}${\"0\".repeat(c)}`,s=\"\"}else s=r.slice(a),o=r.slice(0,a)}let i=s===\"\"?o:`${o}.${s}`;return n?`-${i}`:i},WL=e=>{if(HL(e))return\"0e+0\";let t=lx(e),n=`${qL(t).value}`,r=n.slice(0,1),o=n.slice(1),s=`${xT(t)?\"-\":\"\"}${r}`;o!==\"\"&&(s+=`.${o}`);let i=o.length-t.scale;return`${s}e${i>=0?\"+\":\"\"}${i}`};var HL=e=>e.value===qn,xT=e=>e.valuee.value>qn,px=e=>Qd(e[0]);var yT=l(px,(e,t=0)=>e.scale<=t?e:Rn(e.value/Yd**BigInt(e.scale-t),t)),mx=l(px,(e,t=0)=>{let n=yT(e,t);return JL(e)&&jL(n,e)?hT(n,Rn(LL,t)):n});var hx=l(px,(e,t=0)=>{let n=yT(e,t);return xT(e)&&BL(n,e)?hT(n,Rn($L,t)):n});var x9=globalThis.Boolean;var bT=or((e,t)=>e||t,!1);var Wt={};uo(Wt,{AsyncFiberError:()=>Bj,AsyncFiberErrorTypeId:()=>$j,Done:()=>cb,DoneTypeId:()=>_j,ExceededCapacityError:()=>ub,ExceededCapacityErrorTypeId:()=>Lj,IllegalArgumentError:()=>vf,IllegalArgumentErrorTypeId:()=>Uj,InterruptorStackTrace:()=>Xy,NoSuchElementError:()=>Rj,NoSuchElementErrorTypeId:()=>kj,ReasonTypeId:()=>dj,StackTrace:()=>Qy,TimeoutError:()=>Fj,TimeoutErrorTypeId:()=>Mj,TypeId:()=>lj,UnknownError:()=>Wj,UnknownErrorTypeId:()=>qj,annotate:()=>Hj,annotations:()=>Kj,combine:()=>xj,die:()=>rb,done:()=>G,empty:()=>nb,fail:()=>hj,filterInterruptors:()=>Aj,findDefect:()=>Ij,findDie:()=>Ej,findError:()=>ib,findErrorOption:()=>bj,findFail:()=>yj,findInterrupt:()=>Tj,fromReasons:()=>Bi,hasDies:()=>Sj,hasFails:()=>sb,hasInterrupts:()=>wj,hasInterruptsOnly:()=>ob,interrupt:()=>gj,interruptors:()=>vj,isAsyncFiberError:()=>jj,isCause:()=>eb,isDieReason:()=>pj,isDone:()=>dc,isExceededCapacityError:()=>Nj,isFailReason:()=>Ef,isIllegalArgumentError:()=>Dj,isInterruptReason:()=>mj,isNoSuchElementError:()=>Oj,isReason:()=>tb,isTimeoutError:()=>Pj,isUnknownError:()=>zj,makeDieReason:()=>wf,makeFailReason:()=>If,makeInterruptReason:()=>Tf,map:()=>ns,pretty:()=>ab,prettyErrors:()=>Cj,reasonAnnotations:()=>Jj,squash:()=>Wp});var Xd=e=>zo({op:e.label,[at]:e.evaluate});var ET=\"~effect/Context/Service\",Vt=function(){function e(){}let t=e;Object.setPrototypeOf(t,KL);let n=(r,o)=>(t.key=r,o?.defaultValue&&(t[bx]=bx,t.defaultValue=o.defaultValue),o?.make&&(t.make=o.make),o?.fiberCached&&IT.add(r),t);return arguments.length>0?n(arguments[0],arguments[1]):n},KL={[ET]:ET,...Xd({label:\"Service\",evaluate(e){return Oe(Xe(e.context,this))}}),toJSON(){return{_id:\"Service\",key:this.key}},of(e){return e},context(e){return ki(this,e)},use(e){return Y(t=>e(Xe(t.context,this)))},useSync(e){return Y(t=>Oe(e(Xe(t.context,this))))}},IT=new Set,bx=\"~effect/Context/Reference\",wT=\"~effect/Context\",GL=8,VL=8,Sx=(e,t,n,r)=>{let o=Object.create(YL);return o.cacheRoot=e??o,o.base=t,o.overlay=n,o.depth=r,o._flat=void 0,o.baseHits=0,o},TT=(e,t)=>{t&&(TT(e,t.parent),e.set(t.key,t.value))},vT=e=>{if(e._flat)return e._flat;if(!e.overlay)return e._flat=e.base;let t=new Map(e.base);return TT(t,e.overlay),e._flat=t},ZL=(e,t)=>{let n=new Map(e.mapUnsafe);return t(n),ho(n)},ep=Symbol(),Ex=(e,t)=>{let n=e;for(let o=n.overlay;o;o=o.parent)if(o.key===t)return o.value;let r=n.base.get(t);return r===void 0&&!n.base.has(t)?ep:(n.overlay&&++n.baseHits>=VL&&(n.base=vT(n),n.overlay=void 0,n.depth=0),r)},ho=e=>Sx(void 0,e,void 0,0),YL={get mapUnsafe(){return vT(this)},...ln,[wT]:{_Services:e=>e},toJSON(){return{_id:\"Context\",services:Array.from(this.mapUnsafe).map(([e,t])=>({key:e,value:t}))}},[Se](e){if(!Ju(e))return!1;let t=this.mapUnsafe,n=e.mapUnsafe;if(t.size!==n.size)return!1;for(let[r,o]of t)if(!n.has(r)||!B(o,n.get(r)))return!1;return!0},[be](){return On(this.mapUnsafe.size)}},AT=(e,t)=>e.cacheRoot===t.cacheRoot,Ju=e=>M(e,wT);var CT=e=>!!e[bx],pn=()=>QL,QL=ho(new Map),ki=(e,t)=>ho(new Map([[e.key,t]])),Pt=l(3,(e,t,n)=>XL(e,t.key,n)),XL=(e,t,n)=>{let r=e,o=IT.has(t)?void 0:r.cacheRoot;if(r.depth>=GL){let s=new Map(r.mapUnsafe);return s.set(t,n),Sx(o,s,void 0,0)}return Sx(o,r.base,{key:t,value:n,parent:r.overlay},r.depth+1)};var tp=l(2,(e,t)=>Ku(e,t.key)),Ku=(e,t)=>{let n=Ex(e,t);return n===ep?void 0:n},Fr=l(2,(e,t)=>{let n=Ex(e,t.key);if(n===ep){if(CT(t))return OT(t);throw e$(t)}return n}),Xe=Fr,yx=\"~effect/Context/defaultValue\",OT=e=>yx in e?e[yx]:e[yx]=e.defaultValue(),e$=e=>{let t=new Error(`Service not found${e.key?`: ${String(e.key)}`:\"\"}`);if(t.stack){let n=t.stack.split(`\n`);n.splice(1,3),t.stack=n.join(`\n`)}return t},Ix=l(2,(e,t)=>{let n=Ex(e,t.key);return n!==ep?k(n):CT(t)?k(OT(t)):R()}),np=l(2,(e,t)=>e.mapUnsafe.size===0?t:t.mapUnsafe.size===0?e:ZL(e,n=>t.mapUnsafe.forEach((r,o)=>n.set(o,r)))),kT=(...e)=>{let t=new Map;for(let n=0;n{t.set(o,r)});return ho(t)};var Ae=Vt;var rp=\"~effect/time/Duration\",Tx=BigInt(0),PT=BigInt(1),t$=BigInt(2),n$=BigInt(10);var r$=BigInt(1e3);var op=e=>BigInt(e<0?Math.ceil(e-.5):Math.floor(e+.5)),FT=e=>op(e*1e6),RT=(e,t)=>{let n=e.indexOf(\".\");if(n===-1)return BigInt(e)*t;let r=e[0]===\"-\",o=e.slice(n+1),s=n$**BigInt(o.length),i=(BigInt(e.slice(r?1:0,n))*s+BigInt(o))*t,a=i/s+(i%s*t$>=s?PT:Tx);return r?-a:a};var o$=/^(-?\\d+(?:\\.\\d+)?)\\s+(nanos?|micros?|millis?|seconds?|minutes?|hours?|days?|weeks?)$/,mt=e=>{switch(typeof e){case\"number\":return go(e);case\"bigint\":return dr(e);case\"string\":{if(e===\"Infinity\")return Ur;if(e===\"-Infinity\")return Ri;let t=o$.exec(e);if(!t)break;let[n,r,o]=t;if(o===\"nano\"||o===\"nanos\")return dr(RT(r,PT));if(o===\"micro\"||o===\"micros\")return dr(RT(r,r$));let s=Number(r);switch(o){case\"milli\":case\"millis\":return go(s);case\"second\":case\"seconds\":return c$(s);case\"minute\":case\"minutes\":return u$(s);case\"hour\":case\"hours\":return f$(s);case\"day\":case\"days\":return l$(s);case\"week\":case\"weeks\":return d$(s)}break}case\"object\":{if(e===null)break;if(rp in e)return e;if(Array.isArray(e))return e.length!==2||!e.every(ku)?_T(e):Number.isNaN(e[0])||Number.isNaN(e[1])?Ms:e[0]===-1/0||e[1]===-1/0?Ri:e[0]===1/0||e[1]===1/0?Ur:mn(op(e[0]*1e9+e[1]));let t=e,n=0;return t.weeks&&(n+=t.weeks*6048e5),t.days&&(n+=t.days*864e5),t.hours&&(n+=t.hours*36e5),t.minutes&&(n+=t.minutes*6e4),t.seconds&&(n+=t.seconds*1e3),t.milliseconds&&(n+=t.milliseconds),!t.microseconds&&!t.nanoseconds?mn(n):mn(op(n*1e6+(t.microseconds??0)*1e3+(t.nanoseconds??0)))}}return _T(e)},_T=e=>{throw new Error(`Invalid Input: ${e}`)},UT=Rs(mt),MT={_tag:\"Millis\",millis:0},s$={_tag:\"Infinity\"},i$={_tag:\"NegativeInfinity\"},a$={[rp]:rp,[be](){switch(this.value._tag){case\"Millis\":{let e=this.value.millis*1e6;return Number.isFinite(e)?H(op(e)):On(this.value.millis)}case\"Nanos\":return H(this.value.nanos);default:return Fg(this.value)}},[Se](e){return vx(e)&&m$(this,e)},toString(){switch(this.value._tag){case\"Infinity\":return\"Infinity\";case\"NegativeInfinity\":return\"-Infinity\";case\"Nanos\":return`${this.value.nanos} nanos`;case\"Millis\":return`${this.value.millis} millis`}},toJSON(){switch(this.value._tag){case\"Millis\":return{_id:\"Duration\",_tag:\"Millis\",millis:this.value.millis};case\"Nanos\":return{_id:\"Duration\",_tag:\"Nanos\",nanos:String(this.value.nanos)};case\"Infinity\":return{_id:\"Duration\",_tag:\"Infinity\"};case\"NegativeInfinity\":return{_id:\"Duration\",_tag:\"NegativeInfinity\"}}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},mn=e=>{let t=Object.create(a$);return typeof e==\"number\"?isNaN(e)||e===0||Object.is(e,-0)?t.value=MT:Number.isFinite(e)?Number.isInteger(e)?t.value={_tag:\"Millis\",millis:e}:t.value={_tag:\"Nanos\",nanos:FT(e)}:t.value=e>0?s$:i$:e===Tx?t.value=MT:t.value={_tag:\"Nanos\",nanos:e},t},vx=e=>M(e,rp),tc=e=>e.value._tag!==\"Infinity\"&&e.value._tag!==\"NegativeInfinity\",Gu=e=>{switch(e.value._tag){case\"Millis\":return e.value.millis===0;case\"Nanos\":return e.value.nanos===Tx;case\"Infinity\":case\"NegativeInfinity\":return!1}};var Ms=mn(0),Ur=mn(1/0),Ri=mn(-1/0),dr=e=>mn(e);var go=e=>mn(e),c$=e=>mn(e*1e3),u$=e=>mn(e*6e4),f$=e=>mn(e*36e5),l$=e=>mn(e*864e5),d$=e=>mn(e*6048e5),_n=e=>p$(mt(e),{onMillis:_,onNanos:t=>Number(t)/1e6,onInfinity:()=>1/0,onNegativeInfinity:()=>-1/0});var wx=e=>{let t=mt(e);switch(t.value._tag){case\"Infinity\":case\"NegativeInfinity\":throw new Error(\"Cannot convert infinite duration to nanos\");case\"Nanos\":return t.value.nanos;case\"Millis\":return FT(t.value.millis)}},DT=Rs(wx);var p$=l(2,(e,t)=>{switch(e.value._tag){case\"Millis\":return t.onMillis(e.value.millis);case\"Nanos\":return t.onNanos(e.value.nanos);case\"Infinity\":return t.onInfinity();case\"NegativeInfinity\":return(t.onNegativeInfinity??t.onInfinity)()}}),NT=l(3,(e,t,n)=>e.value._tag===\"Infinity\"||e.value._tag===\"NegativeInfinity\"||t.value._tag===\"Infinity\"||t.value._tag===\"NegativeInfinity\"?n.onInfinity(e,t):e.value._tag===\"Millis\"?t.value._tag===\"Millis\"?n.onMillis(e.value.millis,t.value.millis):n.onNanos(wx(e),t.value.nanos):n.onNanos(e.value.nanos,wx(t)));var Ax=(e,t)=>NT(e,t,{onMillis:(n,r)=>n===r,onNanos:(n,r)=>n===r,onInfinity:(n,r)=>n.value._tag===r.value._tag});var LT=l(2,(e,t)=>NT(e,t,{onMillis:(n,r)=>mn(n-r),onNanos:(n,r)=>mn(n-r),onInfinity:(n,r)=>{let o=n.value._tag,s=r.value._tag;return o===\"Infinity\"?s===\"Infinity\"?Ms:Ur:o===\"NegativeInfinity\"?s===\"NegativeInfinity\"?Ms:Ri:s===\"Infinity\"?Ri:Ur}}));var m$=l(2,(e,t)=>Ax(e,t));var Cx=l(2,(e,t)=>n=>{let r=e(n);if(ie(r))return re(n);let o=t(r.success);return ie(o)?re(n):o}),$T=e=>t=>{let n=e(t);return ie(n)?R():k(n.success)};var Vu=Ae(\"effect/Scheduler\",{fiberCached:!0,defaultValue:()=>new _i}),BT=\"setImmediate\"in globalThis?e=>{let t=globalThis.setImmediate(e);return()=>globalThis.clearImmediate(t)}:e=>{let t=setTimeout(e,0);return()=>clearTimeout(t)},h$=e=>{let t=!1;return Promise.resolve().then(()=>{t||e()}),()=>{t=!0}},Ox=class{buckets=[];scheduleTask(t,n){let r=this.buckets,o=r.length,s,i=0;for(;in);i++)s=r[i];s&&s[0]===n?s[1].push(t):i===o?r.push([n,[t]]):r.splice(i,0,[n,[t]])}drain(){let t=this.buckets;return this.buckets=[],t}},_i=class{executionMode;setImmediate;constructor(t=\"async\",n){this.executionMode=t,this.setImmediate=n??(t===\"sync\"?h$:BT)}shouldYield(t){return t.currentOpCount>=t.maxOpsBeforeYield}makeDispatcher(){return new kx(this.setImmediate)}},kx=class{tasks=new Ox;running=void 0;setImmediate;constructor(t=BT){this.setImmediate=t}scheduleTask(t,n){this.tasks.scheduleTask(t,n),this.running===void 0&&(this.running=this.setImmediate(this.afterScheduled))}afterScheduled=()=>{this.running=void 0,this.runTasks()};runTasks(){let t=this.tasks.drain();for(let n=0;n0;)this.running!==void 0&&(this.running(),this.running=void 0),this.runTasks()}},qT=Ae(\"effect/Scheduler/MaxOpsBeforeYield\",{fiberCached:!0,defaultValue:()=>2048}),zT=Ae(\"effect/Scheduler/PreventSchedulerYield\",{fiberCached:!0,defaultValue:()=>!1});var bo={};uo(bo,{Class:()=>sp,Error:()=>WT,TaggedClass:()=>g$,TaggedError:()=>yo,taggedEnum:()=>x$});var sp=class extends di{constructor(e){super(),e&&Fd(this,e)}},g$=e=>class extends sp{_tag=e},x$=()=>new Proxy({},{get(e,t,n){return t===\"$is\"?$t:t===\"$match\"?y$:r=>({...r,_tag:t})}});function y$(){if(arguments.length===1){let n=arguments[0];return function(r){return n[r._tag](r)}}let e=arguments[0];return arguments[1][e._tag](e)}var WT=Uu,yo=Wo;var HT=\"~effect/encoding/EncodingError\",Go=class extends yo(\"EncodingError\"){[HT]=HT};var ap=e=>_x(typeof e==\"string\"?Ux.encode(e):e),rc=e=>{let t=tv(e),n=t.length;if(n%4!==0)return re(new Go({kind:\"Decode\",module:\"Base64\",input:t,message:`Length must be a multiple of 4, but is ${n}`}));let r=t.indexOf(\"=\");if(r!==-1&&(r$e()(st([J({_tag:ze(\"Some\"),value:n}),J({_tag:ze(\"None\")})]),We({decode:r=>r._tag===\"None\"?R():k(r.value),encode:r=>_e(r)?{_tag:\"Some\",value:r.value}:{_tag:\"None\"}})),toArbitrary:([n])=>(r,o)=>{let s=r.constant(R()),i=r.oneof(s,n.arbitrary.map(k));return _a(r,o,s,i)},toEquivalence:([n])=>tx(n),toFormatter:([n])=>fr({onNone:()=>\"none()\",onSome:r=>`some(${n(r)})`})});return j(t.ast,{value:e})}var _3=wn(\"effect/schema/Option\",Re,({annotations:e,typeParameters:t})=>{let n=si(t[0]);return e===void 0?n:n.annotate(e)});function M3(e){return ow(e).pipe(ne(si(Tn(e)),sR()))}function P3(e){return rg(e).pipe(ne(si(Tn(e)),iR()))}function F3(e,t){return nU(e).pipe(ne(si(Tn(e)),aR(t)))}function U3(e){return Oa(e).pipe(ne(si(Tn(e)),cR()))}function D3(e){return Ar(e).pipe(ne(si(Tn(e)),uR()))}function N3(e,t){let n=t===void 0?\"omit\":t.onNoneEncoding,r=n===null?null:void 0;return Ar(ow(e)).pipe(ne(si(Tn(e)),Pm({decode:o=>o.pipe(Ci(Ad),k),encode:n===\"omit\"?Ai:o=>k(Bu(Ai(o),()=>r))})))}function QU(e,t){let n=Dn()([e,t],([r,o])=>(s,i,a)=>{if(!eT(s))return P(new He(i,s,a));switch(s._tag){case\"Success\":return Ut(kI(r)(s.success,a),{onSuccess:se,onFailure:c=>Pn(i,\"success\",c,s,a)});case\"Failure\":return Ut(kI(o)(s.failure,a),{onSuccess:re,onFailure:c=>Pn(i,\"failure\",c,s,a)})}},{representation:{id:\"effect/schema/Result\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Result(${r[0].runtime}, ${r[1].runtime})`,Type:`Result.Result<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Result from \"effect/Result\"']}),expected:\"Result\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Success\"),success:r}),J({_tag:ze(\"Failure\"),failure:o})]),We({decode:s=>s._tag===\"Success\"?se(s.success):re(s.failure),encode:s=>Tt(s)?{_tag:\"Success\",success:s.success}:{_tag:\"Failure\",failure:s.failure}})),toArbitrary:([r,o])=>(s,i)=>{let a=uD(s,r.terminal?.map(u=>se(u)),o.terminal?.map(u=>re(u))),c=s.oneof(r.arbitrary.map(u=>se(u)),o.arbitrary.map(u=>re(u)));return _a(s,i,a,c)},toEquivalence:([r,o])=>tT(r,o),toFormatter:([r,o])=>lr({onSuccess:s=>`success(${r(s)})`,onFailure:s=>`failure(${o(s)})`})});return j(n.ast,{success:e,failure:t})}var L3=wn(\"effect/schema/Result\",Re,({annotations:e,typeParameters:t})=>{let n=QU(t[0],t[1]);return e===void 0?n:n.annotate(e)}),$3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>{switch(n){case\"label\":return typeof e[n]==\"string\";case\"disallowJsonEncode\":return e[n]===!0;default:return!1}})}),j3=st([Re,$3]);function td(e,t){let n=typeof t?.label==\"string\"?t.label:void 0,r=t?.disallowJsonEncode===!0,o=n!==void 0?r?{label:n,disallowJsonEncode:!0}:{label:n}:r?{disallowJsonEncode:!0}:void 0,s=n!==void 0?Ze(ze(n)):void 0,i=Dn()([e],([a])=>(c,u,f)=>{if(VI(c)){let d=s!==void 0?fs(s(c.label,f),p=>new Ve([\"label\"],p)):te;return lt(d,()=>Ut(Ze(a)(nu(c),f),{onSuccess:()=>c,onFailure:()=>new nt(u,[new Ve([\"value\"],new ge(void 0,c,f))],c,f)}))}return P(new He(u,c,f))},{representation:{id:\"effect/schema/Redacted\",payload:o??null},toCode:({typeParameters:a})=>({runtime:o!==void 0?`Schema.Redacted(${a[0].runtime}, ${N(o)})`:`Schema.Redacted(${a[0].runtime})`,Type:`Redacted.Redacted<${a[0].Type}>`,importDeclarations:['import * as Redacted from \"effect/Redacted\"']}),expected:\"Redacted\",toCodecJson:([a])=>$e()(a,{decode:ue(c=>Wl(c,{label:n})),encode:r?IS(c=>\"Cannot serialize Redacted\"+(_e(c)&&typeof c.value.label==\"string\"?` with label: \"${c.value.label}\"`:\"\")):ue(nu)}),toArbitrary:([a])=>()=>({arbitrary:a.arbitrary.map(c=>Wl(c,{label:n})),terminal:a.terminal?.map(c=>Wl(c,{label:n}))}),toFormatter:()=>globalThis.String,toEquivalence:([a])=>EF(a)});return j(i.ast,{value:e})}var B3=wn(\"effect/schema/Redacted\",j3,({annotations:e,payload:t,typeParameters:n})=>{let r=td(n[0],t??void 0);return e===void 0?r:r.annotate(e)});function q3(e,t){return ne(td(Tn(e),{label:t?.label,disallowJsonEncode:t?.disallowEncode}),{decode:ue(n=>Wl(n,{label:t?.label})),encode:t?.disallowEncode?IS(n=>\"Cannot encode Redacted\"+(_e(n)&&typeof n.value.label==\"string\"?` with label: \"${n.value.label}\"`:\"\")):ue(nu)})(e)}function Zh(e,t){let n=Dn()([e,t],([r,o])=>(s,i,a)=>{if(!tb(s))return P(new He(i,s,a));switch(s._tag){case\"Fail\":return Ut(Ze(r)(s.error,a),{onSuccess:If,onFailure:c=>Pn(i,\"error\",c,s,a)});case\"Die\":return Ut(Ze(o)(s.defect,a),{onSuccess:wf,onFailure:c=>Pn(i,\"defect\",c,s,a)});case\"Interrupt\":return x(s)}},{representation:{id:\"effect/schema/CauseReason\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.CauseReason(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Failure<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause.Failure\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Fail\"),error:r}),J({_tag:ze(\"Die\"),defect:o}),J({_tag:ze(\"Interrupt\"),fiberId:rg(Xn)})]),We({decode:s=>{switch(s._tag){case\"Fail\":return If(s.error);case\"Die\":return wf(s.defect);case\"Interrupt\":return Tf(s.fiberId)}},encode:_})),toArbitrary:([r,o])=>XU(r,o),toEquivalence:([r,o])=>eD(r,o),toFormatter:([r,o])=>tD(r,o)});return j(n.ast,{error:e,defect:t})}var z3=wn(\"effect/schema/CauseReason\",Re,({annotations:e,typeParameters:t})=>{let n=Zh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function XU(e,t){return(n,r)=>{let o=n.constant(Tf()),s=n.oneof(o,n.integer({min:1}).map(Tf),e.arbitrary.map(i=>If(i)),t.arbitrary.map(i=>wf(i)));return _a(n,r,o,s)}}function eD(e,t){return(n,r)=>{if(n._tag!==r._tag)return!1;switch(n._tag){case\"Fail\":return e(n.error,r.error);case\"Die\":return t(n.defect,r.defect);case\"Interrupt\":return n.fiberId===r.fiberId}}}function tD(e,t){return n=>{switch(n._tag){case\"Fail\":return`Fail(${e(n.error)})`;case\"Die\":return`Die(${t(n.defect)})`;case\"Interrupt\":return\"Interrupt\"}}}function Yh(e,t){let n=Dn()([e,t],([r,o])=>{let s=Ye(Zh(r,o));return(i,a,c)=>eb(i)?Ut(Ze(s)(i.reasons,c),{onSuccess:Bi,onFailure:u=>Pn(a,\"failures\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/Cause\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Cause(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Cause<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause\",toCodec:([r,o])=>$e()(Ye(Zh(r,o)),We({decode:Bi,encode:({reasons:s})=>s})),toArbitrary:([r,o])=>nD(r,o),toEquivalence:([r,o])=>rD(r,o),toFormatter:([r,o])=>oD(r,o)});return j(n.ast,{error:e,defect:t})}var W3=wn(\"effect/schema/Cause\",Re,({annotations:e,typeParameters:t})=>{let n=Yh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function nD(e,t){return(n,r)=>{let o=XU(e,t)(n,r),s=n.constant(nb),i=n.array(o.arbitrary).map(Bi);return _a(n,r,s,i)}}function rD(e,t){let n=C0(eD(e,t));return(r,o)=>n(r.reasons,o.reasons)}function oD(e,t){let n=tD(e,t);return r=>`Cause([${r.reasons.map(n).join(\", \")}])`}var H3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>(n===\"includeStack\"||n===\"excludeCause\")&&e[n]===!0)}),J3=st([Re,H3]),sD=e=>(e?.includeStack===!0?1:0)|(e?.excludeCause===!0?2:0),iD=e=>{switch(e){case 0:return;case 1:return{includeStack:!0};case 2:return{excludeCause:!0};case 3:return{includeStack:!0,excludeCause:!0}}},_F=[];function aD(e){let t=sD(e),n=_F[t];if(n!==void 0)return n;let r=iD(t),o=oi(globalThis.Error,{representation:{id:\"effect/schema/Error\",payload:r??null},toCode:()=>({runtime:r!==void 0?`Schema.ErrorInstance(${N(r)})`:\"Schema.ErrorInstance()\",Type:\"globalThis.Error\"}),expected:\"Error\",toCodecJson:()=>$e()($6,rR(r)),toArbitrary:()=>s=>s.string().map(i=>new globalThis.Error(i))});return _F[t]=o,o}var K3=wn(\"effect/schema/Error\",J3,({annotations:e,payload:t})=>{let n=aD(t??void 0);return e===void 0?n:n.annotate(e)}),MF=[];function G3(e){let t=sD(e),n=MF[t];if(n!==void 0)return n;let r=ad.pipe(ne(tw,oR(iD(t))));return MF[t]=r,r}function cD(e,t,n){let r=Dn()([e,t,n],([o,s,i])=>{let a=Yh(s,i);return(c,u,f)=>{if(!Af(c))return P(new He(u,c,f));switch(c._tag){case\"Success\":return Ut(Ze(o)(c.value,f),{onSuccess:Yt,onFailure:d=>Pn(u,\"value\",d,c,f)});case\"Failure\":return Ut(Ze(a)(c.cause,f),{onSuccess:Qt,onFailure:d=>Pn(u,\"cause\",d,c,f)})}}},{representation:{id:\"effect/schema/Exit\",payload:null},toCode:({typeParameters:o})=>({runtime:`Schema.Exit(${o[0].runtime}, ${o[1].runtime}, ${o[2].runtime})`,Type:`Exit.Exit<${o[0].Type}, ${o[1].Type}, ${o[2].Type}>`,importDeclarations:['import * as Exit from \"effect/Exit\"']}),expected:\"Exit\",toCodec:([o,s,i])=>$e()(st([J({_tag:ze(\"Success\"),value:o}),J({_tag:ze(\"Failure\"),cause:Yh(s,i)})]),We({decode:a=>a._tag===\"Success\"?Yt(a.value):Qt(a.cause),encode:a=>et(a)?{_tag:\"Success\",value:a.value}:{_tag:\"Failure\",cause:a.cause}})),toArbitrary:([o,s,i])=>(a,c)=>{let u=nD(s,i)(a,c),f=uD(a,o.terminal?.map(p=>Yt(p)),u.terminal?.map(p=>Qt(p))),d=a.oneof(o.arbitrary.map(p=>Yt(p)),u.arbitrary.map(p=>Qt(p)));return _a(a,c,f,d)},toEquivalence:([o,s,i])=>{let a=rD(s,i);return(c,u)=>{if(c._tag!==u._tag)return!1;switch(c._tag){case\"Success\":return o(c.value,u.value);case\"Failure\":return a(c.cause,u.cause)}}},toFormatter:([o,s,i])=>{let a=oD(s,i);return c=>{switch(c._tag){case\"Success\":return`Exit.Success(${o(c.value)})`;case\"Failure\":return`Exit.Failure(${a(c.cause)})`}}}});return j(r.ast,{value:e,error:t,defect:n})}var V3=wn(\"effect/schema/Exit\",Re,({annotations:e,typeParameters:t})=>{let n=cD(t[0],t[1],t[2]);return e===void 0?n:n.annotate(e)});function uD(e,t,n){return t===void 0?n:n===void 0?t:e.oneof(t,n)}function _a(e,t,n,r){return{arbitrary:n===void 0||t.recursion===void 0?r:e.oneof(t.recursion,n,r),terminal:n}}function PF(e,t,n,r){return r===void 0?e.array(t,n):e.uniqueArray(t,{...n,comparator:r})}function sg(e,t,n,r,o,s){let i=t.constraint,a=i===void 0||i.minLength===void 0&&i.maxLength===void 0?void 0:{...i.minLength!==void 0?{minLength:i.minLength}:{},...i.maxLength!==void 0?{maxLength:i.maxLength}:{}};if(a?.minLength!==void 0&&a.maxLength!==void 0&&a.minLength>a.maxLength)throw new globalThis.Error(\"Unable to derive an arbitrary for size constraints\");let c=a?.minLength??0,u=c===0?e.constant([]):r===void 0?void 0:PF(e,r,{...a,maxLength:c},s),f=_a(e,t,u,PF(e,n,a,s));return{arbitrary:f.arbitrary.map(o),terminal:f.terminal?.map(o)}}function fD(e,t,n,r,o){return sg(e,t,e.tuple(n.arbitrary,r.arbitrary),n.terminal===void 0||r.terminal===void 0?void 0:e.tuple(n.terminal,r.terminal),o,([s],[i])=>B(s,i))}function lD(e,t){let n=Dn()([e,t],([r,o])=>{let s=Ye(ou([r,o]));return(i,a,c)=>i instanceof globalThis.Map?Ut(Ze(s)([...i],c),{onSuccess:u=>new globalThis.Map(u),onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/ReadonlyMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.ReadonlyMap(${r[0].runtime}, ${r[1].runtime})`,Type:`globalThis.ReadonlyMap<${r[0].Type}, ${r[1].Type}>`}),expected:\"ReadonlyMap\",toCodec:([r,o])=>$e()(Ye(ou([r,o])),We({decode:s=>new globalThis.Map(s),encode:s=>[...s.entries()]})),toArbitrary:([r,o])=>(s,i)=>fD(s,i,r,o,a=>new globalThis.Map(a)),toEquivalence:([r,o])=>kd(r,o),toFormatter:([r,o])=>s=>{let i=s.size;if(i===0)return\"ReadonlyMap(0) {}\";let a=globalThis.Array.from(s.entries()).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`ReadonlyMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var Z3=wn(\"effect/schema/ReadonlyMap\",Re,({annotations:e,typeParameters:t})=>{let n=lD(t[0],t[1]);return e===void 0?n:n.annotate(e)});function FF(e,t,n){return J({type:ze(e),nodes:Ye(J({index:Gt,data:t})),edges:Ye(J({index:Gt,source:Gt,target:Gt,data:n}))})}function UF(e,t){let n=-1,r=new Set;for(let o=0;o{let o=kl(n),s=kl(r);if(o.type!==s.type||o.nodes.length!==s.nodes.length||o.edges.length!==s.edges.length)return!1;for(let i=0;i{let s=Rl({type:e,nodes:[],edges:[]}),i=r.constant(s),a=r.array(t.arbitrary).chain(c=>{let u=c.map((d,p)=>({index:p,data:d}));if(u.length===0)return i;let f=r.integer({min:0,max:u.length-1});return r.array(r.tuple(f,f,n.arbitrary)).map(d=>Rl({type:e,nodes:u,edges:d.map(([p,m,g],b)=>({index:b,source:p,target:m,data:g}))}))});return _a(r,o,i,a)}}function dD(e,t,n){let r=Dn()([t,n],([o,s])=>{let i=FF(e,o,s);return(a,c,u)=>!yI(a)||a.mutable||a.type!==e?P(new He(c,a,u)):T(Ze(i)(kl(a),u),f=>UF(f,u))},{representation:{id:\"effect/schema/Graph\",payload:e},toCode:({typeParameters:o})=>({runtime:`Schema.Graph(${N(e)}, ${o[0].runtime}, ${o[1].runtime})`,Type:`Graph.Graph<${o[0].Type}, ${o[1].Type}, ${N(e)}>`,importDeclarations:['import * as Graph from \"effect/Graph\"']}),expected:`an immutable ${e} Graph`,toCodec:([o,s])=>$e()(FF(e,o,s),Zn({decode:UF,encode:(i,a)=>Y3(i,e,a)})),toArbitrary:([o,s])=>X3(e,o,s),toEquivalence:([o,s])=>Q3(o,s),toFormatter:()=>globalThis.String});return j(r.ast,{type:e,node:t,edge:n})}var eG=wn(\"effect/schema/Graph\",io([\"directed\",\"undirected\"]),({annotations:e,payload:t,typeParameters:n})=>{let r=dD(t,n[0],n[1]);return e===void 0?r:r.annotate(e)});function pD(e,t){let n=Dn()([e,t],([r,o])=>{let s=Ye(ou([r,o]));return(i,a,c)=>XM(i)?Ut(Ze(s)(Uh(i),c),{onSuccess:Fh,onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/HashMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.HashMap(${r[0].runtime}, ${r[1].runtime})`,Type:`HashMap.HashMap<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as HashMap from \"effect/HashMap\"']}),expected:\"HashMap\",toCodec:([r,o])=>$e()(Ye(ou([r,o])),We({decode:Fh,encode:Uh})),toArbitrary:([r,o])=>(s,i)=>fD(s,i,r,o,Fh),toEquivalence:([r,o])=>kd(r,o),toFormatter:([r,o])=>s=>{let i=eP(s);if(i===0)return\"HashMap(0) {}\";let a=Uh(s).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`HashMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var tG=wn(\"effect/schema/HashMap\",Re,({annotations:e,typeParameters:t})=>{let n=pD(t[0],t[1]);return e===void 0?n:n.annotate(e)});function mD(e){let t=Dn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>o instanceof globalThis.Set?Ut(Ze(r)([...o],i),{onSuccess:a=>new globalThis.Set(a),onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/ReadonlySet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.ReadonlySet(${n[0].runtime})`,Type:`globalThis.ReadonlySet<${n[0].Type}>`}),expected:\"ReadonlySet\",toCodec:([n])=>$e()(Ye(n),We({decode:r=>new globalThis.Set(r),encode:r=>[...r.values()]})),toArbitrary:([n])=>(r,o)=>sg(r,o,n.arbitrary,n.terminal,s=>new globalThis.Set(s),B),toEquivalence:([n])=>Rd(n),toFormatter:([n])=>r=>{let o=r.size;if(o===0)return\"ReadonlySet(0) {}\";let s=globalThis.Array.from(r.values()).sort().map(i=>`${n(i)}`);return`ReadonlySet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var nG=wn(\"effect/schema/ReadonlySet\",Re,({annotations:e,typeParameters:t})=>{let n=mD(t[0]);return e===void 0?n:n.annotate(e)});function hD(e){let t=Dn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>sP(o)?Ut(Ze(r)(Be(o),i),{onSuccess:Nh,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/HashSet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.HashSet(${n[0].runtime})`,Type:`HashSet.HashSet<${n[0].Type}>`}),expected:\"HashSet\",toCodec:([n])=>$e()(Ye(n),We({decode:Nh,encode:Be})),toArbitrary:([n])=>(r,o)=>sg(r,o,n.arbitrary,n.terminal,Nh,B),toEquivalence:([n])=>Rd(n),toFormatter:([n])=>r=>{let o=iP(r);if(o===0)return\"HashSet(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`HashSet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var rG=wn(\"effect/schema/HashSet\",Re,({annotations:e,typeParameters:t})=>{let n=hD(t[0]);return e===void 0?n:n.annotate(e)});function gD(e){let t=Dn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>Xm(o)?Ut(Ze(r)(Be(o),i),{onSuccess:eh,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/Chunk\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.Chunk(${n[0].runtime})`,Type:`Chunk.Chunk<${n[0].Type}>`}),expected:\"Chunk\",toCodec:([n])=>$e()(Ye(n),We({decode:eh,encode:Be})),toArbitrary:([n])=>(r,o)=>sg(r,o,n.arbitrary,n.terminal,eh),toEquivalence:([n])=>vE(n),toFormatter:([n])=>r=>{let o=d_(r);if(o===0)return\"Chunk(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`Chunk(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var oG=wn(\"effect/schema/Chunk\",Re,({annotations:e,typeParameters:t})=>{let n=gD(t[0]);return e===void 0?n:n.annotate(e)}),xD=oi(globalThis.RegExp,{representation:{id:\"effect/schema/RegExp\",payload:null},toCode:()=>({runtime:\"Schema.RegExp\",Type:\"globalThis.RegExp\"}),expected:\"RegExp\",toCodecJson:()=>$e()(J({source:X,flags:X}),Zn({decode:(e,t)=>Sc({try:()=>new globalThis.RegExp(e.source,e.flags),catch:()=>new ge({expected:\"valid RegExp source and flags\"},e,t)}),encode:e=>x({source:e.source,flags:e.flags})})),toArbitrary:()=>e=>e.tuple(e.constantFrom(\".\",\".*\",\"\\\\d+\",\"\\\\w+\",\"[a-z]+\",\"[A-Z]+\",\"[0-9]+\",\"^[a-zA-Z0-9]+$\",\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"),e.uniqueArray(e.constantFrom(\"g\",\"i\",\"m\",\"s\",\"u\",\"y\"),{minLength:0,maxLength:6}).map(t=>t.join(\"\"))).map(([t,n])=>new globalThis.RegExp(t,n)),toEquivalence:()=>(e,t)=>e.source===t.source&&e.flags===t.flags}),sG=sn(\"effect/schema/RegExp\",xD),yD=X.annotate({expected:\"a string that will be decoded as a URL\"}),nd=oi(globalThis.URL,{representation:{id:\"effect/schema/URL\",payload:null},toCode:()=>({runtime:\"Schema.URL\",Type:\"globalThis.URL\"}),expected:\"URL\",toCodecJson:()=>$e()(yD,kS),toArbitrary:()=>e=>e.webUrl().map(t=>new globalThis.URL(t)),toEquivalence:()=>(e,t)=>e.toString()===t.toString()}),iG=sn(\"effect/schema/URL\",nd),aG=yD.pipe(ne(nd,kS));function mw(e,t,n){let r={...t};if(e?.minimum!==void 0){let o=n===void 0?e.minimum:n(e.minimum),s=e.exclusiveMinimum?new globalThis.Date(o.getTime()+1):o;(r.min===void 0||s.getTime()>r.min.getTime())&&(r.min=s)}if(e?.maximum!==void 0){let o=n===void 0?e.maximum:n(e.maximum),s=e.exclusiveMaximum?new globalThis.Date(o.getTime()-1):o;(r.max===void 0||s.getTime()e instanceof globalThis.Date&&!globalThis.Number.isNaN(e.getTime()),{representation:{id:\"effect/schema/Date\",payload:null},toCode:()=>({runtime:\"Schema.Date\",Type:\"globalThis.Date\"}),expected:\"a valid Date\",toCodecJson:()=>$e()(bD,OS),toArbitrary:()=>(e,t)=>e.date(mw(t?.constraint?.ordered?.order===wi?t.constraint.ordered:void 0,{noInvalidDate:!0}))}),cG=sn(\"effect/schema/Date\",er),uG=bD.pipe(ne(er,OS)),fG=Uo.pipe(ne(er,Zk)),rd=Cr(vx,{representation:{id:\"effect/schema/Duration\",payload:null},toCode:()=>({runtime:\"Schema.Duration\",Type:\"Duration.Duration\",importDeclarations:['import * as Duration from \"effect/Duration\"']}),expected:\"Duration\",toCodecJson:()=>$e()(st([J({_tag:ze(\"Infinity\")}),J({_tag:ze(\"NegativeInfinity\")}),J({_tag:ze(\"Nanos\"),value:Fo}),J({_tag:ze(\"Millis\"),value:Uo})]),We({decode:e=>{switch(e._tag){case\"Infinity\":return Ur;case\"NegativeInfinity\":return Ri;case\"Nanos\":return dr(e.value);case\"Millis\":return go(e.value)}},encode:e=>{switch(e.value._tag){case\"Infinity\":return{_tag:\"Infinity\"};case\"NegativeInfinity\":return{_tag:\"NegativeInfinity\"};case\"Nanos\":return{_tag:\"Nanos\",value:e.value.nanos};case\"Millis\":return{_tag:\"Millis\",value:e.value.millis}}}})),toArbitrary:()=>e=>e.oneof(e.constant(Ur),e.constant(Ri),e.bigInt().map(dr),e.maxSafeInteger().map(go)),toFormatter:()=>globalThis.String,toEquivalence:()=>Ax}),lG=sn(\"effect/schema/Duration\",rd),dG=X.annotate({expected:\"a string that will be decoded as a Duration\"}),hw=dG.pipe(ne(rd,Yk)),pG=Fo.pipe(ne(rd,Qk)),mG=uu.pipe(ne(rd,Xk)),SD=X.annotate({expected:\"a string that will be decoded as a BigDecimal\"}),ED=20,ID=\"Unable to derive an arbitrary for the ordered BigDecimal constraints\";function Qh(e,t){return _s(e,t).value}function hG(e,t,n){return n?Qh(hx(e,t),t)+globalThis.BigInt(1):Qh(mx(e,t),t)}function gG(e,t,n){return n?Qh(mx(e,t),t)-globalThis.BigInt(1):Qh(hx(e,t),t)}function xG(e){return Math.max(ED,e.minimum?.scale??0,e.maximum?.scale??0,e.exclusiveMinimum&&e.minimum!==void 0?e.minimum.scale+1:0,e.exclusiveMaximum&&e.maximum!==void 0?e.maximum.scale+1:0)}function YI(e,t){let n={};if(e.minimum!==void 0&&(n.min=hG(e.minimum,t,e.exclusiveMinimum===!0)),e.maximum!==void 0&&(n.max=gG(e.maximum,t,e.exclusiveMaximum===!0)),!(n.min!==void 0&&n.max!==void 0&&n.min>n.max))return n}function yG(e){let t=xG(e);if(YI(e,t)===void 0)throw new globalThis.Error(ID);let n=0,r=t;for(;n({runtime:\"Schema.BigDecimal\",Type:\"BigDecimal.BigDecimal\",importDeclarations:['import * as BigDecimal from \"effect/BigDecimal\"']}),expected:\"BigDecimal\",toCodecJson:()=>$e()(SD,RS),toArbitrary:()=>(e,t)=>{let n=t.constraint?.ordered?.order===Jo?t.constraint.ordered:void 0;return n===void 0?e.tuple(e.bigInt(),e.integer({min:0,max:ED})).map(([r,o])=>Rn(r,o)):e.integer(yG(n)).chain(r=>{let o=YI(n,r);if(o===void 0)throw new globalThis.Error(ID);return e.bigInt(o).map(s=>Rn(s,r))})},toFormatter:()=>e=>mo(e),toEquivalence:()=>dx}),bG=sn(\"effect/schema/BigDecimal\",gw),SG=SD.pipe(ne(gw,RS)),EG=X.annotate({expected:\"a string that will be decoded as JSON\",contentMediaType:\"application/json\"});function wD(e,t){return EG.pipe(ne(e,mR(t)))}var IG=wD(tw),xw=oi(globalThis.File,{representation:{id:\"effect/schema/File\",payload:null},toCode:()=>({runtime:\"Schema.File\",Type:\"globalThis.File\"}),expected:\"File\",toCodecJson:()=>$e()(J({data:X.check(cw()),type:X,name:X,lastModified:Uo}),Zn({decode:(e,t)=>lr(rc(e.data),{onFailure:()=>P(new ge({expected:\"a valid Base64 string\"},e.data,t)),onSuccess:n=>{let r=new globalThis.Uint8Array(n);return x(new globalThis.File([r],e.name,{type:e.type,lastModified:e.lastModified}))}}),encode:(e,t)=>bc({try:async()=>{let n=new globalThis.Uint8Array(await e.arrayBuffer());return{data:ap(n),type:e.type,name:e.name,lastModified:e.lastModified}},catch:()=>new ge({expected:\"a readable File\"},e,t)})}))}),wG=sn(\"effect/schema/File\",xw),yw=oi(globalThis.FormData,{representation:{id:\"effect/schema/FormData\",payload:null},toCode:()=>({runtime:\"Schema.FormData\",Type:\"globalThis.FormData\"}),expected:\"FormData\",toCodecJson:()=>$e()(Ye(ou([X,st([J({_tag:ka(\"String\"),value:X}),J({_tag:ka(\"File\"),value:xw})])])),Zn({decode:e=>{let t=new globalThis.FormData;for(let[n,r]of e)t.append(n,r.value);return x(t)},encode:e=>x(globalThis.Array.from(e.entries()).map(([t,n])=>typeof n==\"string\"?[t,{_tag:\"String\",value:n}]:[t,{_tag:\"File\",value:n}]))}))}),TG=sn(\"effect/schema/FormData\",yw);function vG(e){return yw.pipe(ne(e,hR))}var bw=oi(globalThis.URLSearchParams,{representation:{id:\"effect/schema/URLSearchParams\",payload:null},toCode:()=>({runtime:\"Schema.URLSearchParams\",Type:\"globalThis.URLSearchParams\"}),expected:\"URLSearchParams\",toCodecJson:()=>$e()(X.annotate({expected:\"a query string that will be decoded as URLSearchParams\"}),We({decode:e=>new globalThis.URLSearchParams(e),encode:e=>e.toString()}))}),AG=sn(\"effect/schema/URLSearchParams\",bw);function CG(e){return bw.pipe(ne(e,gR))}var OG=X.annotate({expected:\"a string that will be decoded as a number\"}).pipe(ne(uu,_c)),kG=X.annotate({expected:\"a string that will be decoded as a finite number\"}).pipe(ne(Xn,_c)),RG=j(yE).pipe(ne(Fo,Fm)),TD=X.check(aw()),_G=X.annotate({expected:\"a string that will be decoded as a trimmed string\"}).pipe(ne(TD,Gk())),MG=X.annotate({expected:\"a base64 encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,fR)),PG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,lR)),FG=X.annotate({expected:\"a hex encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,dR)),UG=X.annotate({expected:\"a URI component encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,pR)),QI=st([Xn,XF,X]),DG=J({issues:Ye(J({message:X,path:Ar(Ye(st([QI,J({key:QI})])))}))}),NG=io([0,1]).pipe(ne(ng,We({decode:e=>e===1,encode:e=>e?1:0}))),vD=X.annotate({expected:\"a base64 encoded string that will be decoded as Uint8Array\",format:\"byte\",contentEncoding:\"base64\"}),od=oi(globalThis.Uint8Array,{representation:{id:\"effect/schema/Uint8Array\",payload:null},toCode:()=>({runtime:\"Schema.Uint8Array\",Type:\"globalThis.Uint8Array\"}),expected:\"Uint8Array\",toCodecJson:()=>$e()(vD,_S),toArbitrary:()=>e=>e.uint8Array()}),LG=sn(\"effect/schema/Uint8Array\",od),$G=vD.pipe(ne(od,_S)),jG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a Uint8Array\"}).pipe(ne(od,{decode:Dk(),encode:Rm()})),BG=X.annotate({expected:\"a hex encoded string that will be decoded as a Uint8Array\"}).pipe(ne(od,{decode:Lk(),encode:_m()})),sd=Cr(e=>yS(e)&&gk(e),{representation:{id:\"effect/schema/DateTimeUtc\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeUtc\",Type:\"DateTime.Utc\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Utc\",toCodecJson:()=>$e()(X,FS),toArbitrary:()=>(e,t)=>e.date(mw(t?.constraint?.ordered?.order===SS?t.constraint.ordered:void 0,{noInvalidDate:!0},vm)).map(n=>yk(n)),toFormatter:()=>e=>e.toString(),toEquivalence:()=>bS}),qG=sn(\"effect/schema/DateTimeUtc\",sd),zG=er.pipe(ne(sd,{decode:CS(),encode:ue(vm)})),WG=X.annotate({expected:\"a string that will be decoded as a DateTime.Utc\"}).pipe(ne(sd,FS)),HG=Uo.pipe(ne(sd,{decode:CS(),encode:ue(wk)})),AD=Cr(mk,{representation:{id:\"effect/schema/TimeZoneOffset\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneOffset\",Type:\"DateTime.TimeZone.Offset\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Offset\",toCodecJson:()=>$e()(Uo,xR),toArbitrary:()=>e=>e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Xf(t)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.offset===t.offset}),JG=sn(\"effect/schema/TimeZoneOffset\",AD),CD=X.annotate({expected:\"an IANA time zone identifier\"}),Sw=Cr(hk,{representation:{id:\"effect/schema/TimeZoneNamed\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneNamed\",Type:\"DateTime.TimeZone.Named\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Named\",toCodecJson:()=>$e()(CD,MS),toArbitrary:()=>e=>e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(ES)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.id===t.id}),KG=sn(\"effect/schema/TimeZoneNamed\",Sw),GG=CD.pipe(ne(Sw,MS)),OD=X.annotate({expected:\"a time zone string (IANA identifier or offset like +03:00)\"}),Ew=Cr(pk,{representation:{id:\"effect/schema/TimeZone\",payload:null},toCode:()=>({runtime:\"Schema.TimeZone\",Type:\"DateTime.TimeZone\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone\",toCodecJson:()=>$e()(OD,PS),toArbitrary:()=>e=>e.oneof(e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Xf(t)),e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(ES))),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>Vs(e)===Vs(t)}),VG=sn(\"effect/schema/TimeZone\",Ew),ZG=OD.pipe(ne(Ew,PS)),kD=X.annotate({expected:\"a zoned DateTime string (e.g. 2024-01-01T00:00:00.000+00:00[Europe/London])\"}),Iw=Cr(e=>yS(e)&&xk(e),{representation:{id:\"effect/schema/DateTimeZoned\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeZoned\",Type:\"DateTime.Zoned\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Zoned\",toCodecJson:()=>$e()(kD,US),toArbitrary:()=>(e,t)=>e.tuple(e.date(mw(t?.constraint?.ordered?.order===SS?t.constraint.ordered:void 0,{max:new globalThis.Date(864e13-840*60*1e3),min:new globalThis.Date(-864e13+840*60*1e3),noInvalidDate:!0},vm)),e.constantFrom(\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\")).map(([n,r])=>bk(n,{timeZone:r})),toFormatter:()=>e=>Am(e),toEquivalence:()=>bS}),YG=sn(\"effect/schema/DateTimeZoned\",Iw),QG=kD.pipe(ne(Iw,US)),XG=globalThis.Symbol.for(\"immer-draftable\"),XI={};function ww(e,t,n,r,o){let s=t6(n,t,r),i=RD(t),a=class extends e{constructor(...[c,u]){let d=u?.[\"~payload\"],p=d?.token===XI?d.value:n.make(c??{},u);super(p,{...u,disableChecks:!0,\"~payload\":{token:XI,value:p}})}static[Hl]=Hl;get[i](){return i}static[XG]=!0;static identifier=t;static fields=n.fields;static get ast(){return s(this).ast}static pipe(){return K(this,arguments)}static rebuild(c){return s(this).rebuild(c)}static make(c,u){return new this(c,u)}static makeOption(c,u){return $h(s(this))(c??{},u)}static makeEffect(c,u){return s(this).makeEffect(c??{},u)}static annotate(c){return this.rebuild(Ir(this.ast,c))}static annotateKey(c){return this.rebuild(pl(this.ast,c))}static check(...c){return this.rebuild(ko(this.ast,c))}static extend(c){return(u,f)=>{let d=id(u)?u:J(u),p={...n.fields,...d.fields},m=zm(p,n.ast.checks,{identifier:c});return ww(this,c,rw(ko(m,d.ast.checks),p),f,o)}}static mapFields(c,u){return n.mapFields(c,u)}};return o!==void 0&&Object.assign(a.prototype,o(t)),a}function e6(e){return new Te(ue(t=>new e(t,{\"~payload\":{token:XI,value:t}})),Sn())}function RD(e){return`~effect/Schema/Class/${e}`}function t6(e,t,n){let r;return o=>{if(r!==void 0)return r;let s=RD(t),i=u=>u instanceof o||M(u,s),a=e6(o),c=j(new ia([e.ast],()=>(u,f,d)=>i(u)?x(u):P(new He(f,u,d)),{identifier:t,[xm]:([u])=>({isConstructed:i,link:new Je(u,a)}),toCodec:([u])=>new Je(u.ast,a),toArbitrary:([u])=>()=>({arbitrary:u.arbitrary.map(f=>new o(f)),terminal:u.terminal?.map(f=>new o(f))}),toFormatter:([u])=>f=>`${o.identifier}(${u(f)})`,[Kf]:Fc(e.ast),...n}));return r=ne(c,a)(e)}}function id(e){return tg(e)}var _D=e=>(t,n)=>{let r=id(t)?t:J(t);return ww(sp,e,r,n,o=>({toString(){return`${o}(${N({...this})})`}}))},n6=e=>(t,n,r)=>{let o=id(n)?n.mapFields(s=>({_tag:ka(t),...s}),{unsafePreserveChecks:!0}):og(t,n);return _D(e??t)(o,r)},MD=e=>(t,n)=>{let r=id(t)?t:J(t);return ww(Uu,e,r,n,s=>({name:s}))},r6=e=>(t,n,r)=>{let o=id(n)?n.mapFields(s=>({_tag:ka(t),...s}),{unsafePreserveChecks:!0}):og(t,n);return MD(e??t)(o,r)};function o6(e){let t=KP(e.ast);return n=>t(n,{})}function s6(e){return t=>t.annotate({toFormatter:e})}function i6(e,t){return n(e.ast);function n(o){let s=Co(o)?.toFormatter;if(typeof s==\"function\")return s(Qs(o)?o.typeParameters.map(n):[]);if(t?.onBefore){let i=t.onBefore(o,n);if(i!==void 0)return i}return r(o)}function r(o){switch(o._tag){default:return N;case\"Never\":return()=>\"never\";case\"Void\":return()=>\"void\";case\"Arrays\":{let s=o.elements.map(n),i=o.rest.map(n);return a=>{let c=[],u=0;for(;u0){let[f,...d]=i;for(;un(a.type)),i=o.indexSignatures.map(a=>n(a.type));return o.propertySignatures.length===0&&o.indexSignatures.length===0?N:a=>{let c=[],u=new Set;for(let f=0;f0?\"{ \"+c.join(\", \")+\" }\":\"{}\"}}case\"Union\":{let s=Jt(o).types,i=c=>cl(c,s),a=new Map(s.map((c,u)=>[c,[Ia(c),n(o.types[u])]]));return c=>{let u=i(c);for(let f=0;fn(o.thunk()));return i=>s()(i)}}}}function a6(e){return t=>t.annotate({toEquivalence:e})}function c6(e){return GP(e.ast)}function u6(e,t){return zI(e.ast,t)}function PD(e,t){let n=zI(ig(e.ast),t);return uF(n,t)}function FD(e){return j(ig(e.ast),{schema:e})}var ig=la(e=>{let t=l6(e,ig),n=e.context;return t===e||n===void 0?t:Vm(t,Tw(n))});function Tw(e){return e.constructorDefault===void 0?e:new Er(e.isOptional,e.isMutable,void 0,e.annotations)}function UD(e){if(e.propertySignatures.some(t=>typeof t.name!=\"string\"))throw new globalThis.Error(\"Objects property names must be strings\",{cause:e})}function DD(e){return t=>{let n=new Map;for(let s=0;s{s=nn(s),i=nn(i);let a=e(s),c=e(i);return a!==c?a-c:n.get(s)-n.get(i)});return r.some((s,i)=>s!==t[i])?r:t}}var f6=DD(e=>{switch(e._tag){case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function l6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecJson??e.annotations?.toCodec;if(!un(n))return Ne(e,[SE]);let r=e.typeParameters.map(s=>Ll(nn(s))),o=n(r);return o===void 0?e:Ne(e,[Nc(o,t)])}case\"Unknown\":return Ne(e,[SE]);case\"ObjectKeyword\":return Ne(e,[i_]);case\"Undefined\":case\"Void\":case\"Literal\":case\"Number\":return e.toCodecJson();case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return UD(e),e.recur(t,Ym);case\"Union\":{let n=f6(e.types);return n!==e.types?new En(n,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}function ND(e){return j(LD(Jt(e.ast)))}var LD=It(e=>{let t=d6(e,LD);return t!==e&&e.context!==void 0?Vm(t,Tw(e.context)):t});function d6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecIso??e.annotations?.toCodec;if(un(n)){let r=n(e.typeParameters.map(o=>Ll(o)));return Ne(e,[Nc(r,t)])}return e}case\"Arrays\":case\"Objects\":case\"Union\":case\"Suspend\":return e.recur(t)}return e}function lu(e){return j(BD(e.ast),{schema:e})}function p6(e){return j(qD(e.ast))}function m6(e,t){let n=Ac(e.ast)??lS(e.ast),r=KF(lu(e));return o=>r(o).pipe($(s=>h6(s,{rootName:n,...t})))}function h6(e,t){let n=t.rootName??\"root\",r=t.arrayItemName??\"item\",o=t.pretty??!0,s=t.indent??\" \",i=t.sortKeys??!0,a=new Set,c=[];return f(n,e,0),c.join(o?`\n`:\"\");function u(d,p){c.push(o?s.repeat(d)+p:p)}function f(d,p,m,g){let{attrs:b,safe:I}=ru.tagInfo(d,g);if(p===void 0)u(m,`<${I}${b}/>`);else if(typeof p==\"string\")u(m,`<${I}${b}>${ru.escapeText(p)}`);else if(typeof p!=\"object\"||p===null)u(m,`<${I}${b}>${ru.escapeText(N(p))}`);else{if(a.has(p))throw new globalThis.Error(\"Cycle detected while serializing to XML.\",{cause:p});a.add(p);try{if(globalThis.globalThis.Array.isArray(p)){if(p.length===0){u(m,`<${I}${b}/>`);return}u(m,`<${I}${b}>`);for(let h of p)f(r,h,m+1);u(m,``);return}let w=p,E=Object.keys(w);if(i&&E.sort(),E.length===0){u(m,`<${I}${b}/>`);return}u(m,`<${I}${b}>`);for(let h of E)f(ru.parseTagName(h).safe,w[h],m+1,h);u(m,``)}finally{a.delete(p)}}}}var ru={escapeText(e){return e.replace(/&/g,\"&\").replace(//g,\">\")},escapeAttribute(e){return e.replace(/&/g,\"&\").replace(/\"/g,\""\").replace(//g,\">\")},parseTagName(e){let t=e,n=e;return/^[A-Za-z_]/.test(n)||(n=\"_\"+n),n=n.replace(/[^A-Za-z0-9._-]/g,\"_\"),/^xml/i.test(n)&&(n=\"_\"+n),{safe:n,changed:n!==t}},tagInfo(e,t){let{changed:n,safe:r}=ru.parseTagName(e),s=n||t&&t!==e?` data-name=\"${ru.escapeAttribute(t??e)}\"`:\"\";return{safe:r,attrs:s}}},g6=DD(e=>{switch(e._tag){case\"Null\":case\"Boolean\":case\"Number\":case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function x6(e,t,n){switch(e._tag){case\"Declaration\":{let r=e.typeParameters.map(u=>j(t(nn(u)))),o=e.annotations?.toCodecStringTree;if(un(o)){let u=o(r);return u===void 0?e:Ne(e,[Nc(u,t)])}let s=e.annotations?.toCodecJson,i=un(s)?s(r):void 0,a=i===void 0?e.annotations?.toCodec:void 0,c=i??(un(a)?a(r):void 0);return c===void 0?n(e):Ne(e,[Nc(c,t)])}case\"Null\":return Ne(e,[y6]);case\"Boolean\":return Ne(e,[b6]);case\"Unknown\":case\"ObjectKeyword\":return Ne(e,[EE]);case\"Enum\":case\"Number\":case\"Literal\":case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return UD(e),e.recur(t,Ym);case\"Union\":{let r=g6(e.types);return r!==e.types?new En(r,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}var y6=new Je(new Fn(\"null\"),new Te(ue(()=>null),ue(()=>\"null\"))),b6=new Je(new En([new Fn(\"true\"),new Fn(\"false\")],\"anyOf\"),new Te(ue(e=>e===\"true\"),sa())),$D=new Te(ue(e=>typeof e==\"string\"?[e]:e),Sn()),jD=e=>e.transformation===$D,BD=la(e=>{let t=x6(e,BD,n=>{throw new globalThis.Error(\"Missing structural codec for StringTree\",{cause:n})});return t!==e&&e.context!==void 0?Vm(t,Tw(e.context)):t},{stopAt:jD}),DF=e=>dt(e)?Lc(Kr):Kr,qD=la(e=>{let t=S6(e);if(tE(t)){let n=$c(new En([new Gr(t.isMutable,t.elements.map(DF),t.rest.map(DF)),Oo],\"anyOf\"),t,$D);return dt(e)?Lc(n):n}return t},{stopAt:jD});function S6(e){return e._tag===\"Declaration\"||e._tag===\"Arrays\"||e._tag===\"Objects\"||e._tag===\"Union\"||e._tag===\"Suspend\"?e.recur(qD):e}var E6=fe(\"effect/schema/isGreaterThanDate\",J({exclusiveMinimum:er}),({annotations:e,payload:t})=>PU(t.exclusiveMinimum,e)),I6=fe(\"effect/schema/isGreaterThanOrEqualToDate\",J({minimum:er}),({annotations:e,payload:t})=>FU(t.minimum,e)),w6=fe(\"effect/schema/isLessThanDate\",J({exclusiveMaximum:er}),({annotations:e,payload:t})=>UU(t.exclusiveMaximum,e)),T6=fe(\"effect/schema/isLessThanOrEqualToDate\",J({maximum:er}),({annotations:e,payload:t})=>DU(t.maximum,e)),v6=fe(\"effect/schema/isBetweenDate\",J({minimum:er,maximum:er,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>NU(t,e)),A6=fe(\"effect/schema/isGreaterThanBigInt\",J({exclusiveMinimum:Fo}),({annotations:e,payload:t})=>LU(t.exclusiveMinimum,e)),C6=fe(\"effect/schema/isGreaterThanOrEqualToBigInt\",J({minimum:Fo}),({annotations:e,payload:t})=>$U(t.minimum,e)),O6=fe(\"effect/schema/isLessThanBigInt\",J({exclusiveMaximum:Fo}),({annotations:e,payload:t})=>jU(t.exclusiveMaximum,e)),k6=fe(\"effect/schema/isLessThanOrEqualToBigInt\",J({maximum:Fo}),({annotations:e,payload:t})=>BU(t.maximum,e)),R6=fe(\"effect/schema/isBetweenBigInt\",J({minimum:Fo,maximum:Fo,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>qU(t,e));function _6(e){let t=ND(e);return xF(PI(t),_I(t))}function M6(e){return Vh()}function P6(e){return Vh()}function F6(e,t){return n=>j(Ir(n.ast,{toCodecIso:()=>new Je(e.ast,Mm(t))}),{schema:n})}function U6(e){let t=FD(e),n=PI(t),r=_I(t);return{empty:[],diff:(o,s)=>lF(n(o),n(s)),combine:(o,s)=>[...o,...s],patch:(o,s)=>{let i=n(o),a=dF(s,i);return Object.is(a,i)?o:r(a)}}}function D6(e){let t=rU(()=>n),n=st([e,Ye(t),Gl(X,t)]);return n}var ad=j(Ir(Dc,{toCode:()=>({runtime:\"Schema.Json\",Type:\"Schema.Json\"})})),N6=Gl(X,ad),L6=sn(\"effect/schema/Json\",ad),$6=J({message:X,name:Oa(X),stack:Oa(X),cause:Oa(ad)}),zD=j(Ir(s_,{toCode:()=>({runtime:\"Schema.MutableJson\",Type:\"Schema.MutableJson\"})})),j6=sn(\"effect/schema/MutableJson\",zD);function B6(e){return Co(e.ast)}function q6(e){return e.ast.context?.annotations}var An={};uo(An,{Array:()=>J8,Boolean:()=>y1,ConfigError:()=>xu,FalseValues:()=>x1,LogLevel:()=>S1,Port:()=>b1,Record:()=>H8,TrueValues:()=>g1,all:()=>l1,boolean:()=>nY,date:()=>cY,duration:()=>rY,fail:()=>K8,finite:()=>Q8,int:()=>X8,isConfig:()=>u1,literal:()=>eY,literals:()=>tY,logLevel:()=>sY,map:()=>f1,mapOrFail:()=>L8,nested:()=>uY,nonEmptyString:()=>Z8,number:()=>Y8,option:()=>q8,orElse:()=>$8,port:()=>oY,redacted:()=>iY,schema:()=>vn,string:()=>V8,succeed:()=>G8,unwrap:()=>p1,url:()=>aY,withDefault:()=>d1});var gu={};uo(gu,{ConfigProvider:()=>Fa,SourceError:()=>lg,constantCase:()=>x8,fromDir:()=>P8,fromDotEnv:()=>M8,fromDotEnvContents:()=>r1,fromEnv:()=>n1,fromEnvRecord:()=>Kw,fromUnknown:()=>E8,layer:()=>b8,layerAdd:()=>S8,make:()=>pg,makeArray:()=>Hw,makeRecord:()=>dg,makeValue:()=>md,mapInput:()=>Jw,nested:()=>y8,orElse:()=>Ww});var WD=\"~effect/platform/PlatformError\",du=class extends yo(\"BadArgument\"){get message(){return`${this.module}.${this.method}${this.description?`: ${this.description}`:\"\"}`}};var HD=class extends yo(\"PlatformError\"){constructor(t){\"cause\"in t?super({reason:t,cause:t.cause}):super({reason:t})}[WD]=WD;get message(){return this.reason.message}};var W6=\"~effect/Stream\",H6={_R:_,_E:_,_A:_},J6={[W6]:H6,pipe(){return K(this,arguments)}},JD=e=>{let t=Object.create(J6);return t.channel=e,t};var G6=\"~effect/Sink\";var V6={_A:_,_In:_,_L:_,_E:_,_R:_},Z6={[G6]:V6,pipe(){return K(this,arguments)}};var Y6=e=>{let t=Object.create(Z6);return t.transform=e,t},KD=e=>Ce((t,n)=>x(T(e.transform(t,n),G)));var GD=e=>Y6(t=>{let n=[];if(e<=0)return x([n]);let r;return t.pipe(T(o=>{if(n.length+o.length<=e)return n.push(...o),n.length===e?G():te;for(let s=0;sx([n,r])))});var Do={};uo(Do,{DefaultChunkSize:()=>iN,Do:()=>FZ,TypeId:()=>sN,accumulate:()=>xZ,aggregate:()=>uZ,aggregateWithin:()=>Dw,bind:()=>DZ,bindEffect:()=>NZ,bindTo:()=>LZ,broadcast:()=>PN,broadcastN:()=>fZ,buffer:()=>bV,bufferArray:()=>SV,callback:()=>uN,catch:()=>dd,catchCause:()=>yN,catchCauseFilter:()=>CV,catchCauseIf:()=>AV,catchFilter:()=>EN,catchIf:()=>SN,catchReason:()=>TV,catchReasons:()=>vV,catchTag:()=>IV,catchTags:()=>wV,changes:()=>yZ,changesWith:()=>FN,changesWithEffect:()=>bZ,chunks:()=>ON,collect:()=>gZ,combine:()=>JV,combineArray:()=>KV,concat:()=>ud,cross:()=>Q5,crossWith:()=>gN,debounce:()=>XV,decodeText:()=>SZ,die:()=>d5,drain:()=>L5,drainFork:()=>$5,drop:()=>Uw,dropRight:()=>zV,dropUntil:()=>jV,dropUntilEffect:()=>BV,dropWhile:()=>AN,dropWhileEffect:()=>CN,dropWhileFilter:()=>qV,empty:()=>ai,encodeText:()=>EZ,ensuring:()=>kZ,fail:()=>cd,failCause:()=>fN,failCauseSync:()=>p5,failSync:()=>l5,filter:()=>lV,filterEffect:()=>pV,filterMap:()=>dV,filterMapEffect:()=>mV,flatMap:()=>hu,flatten:()=>Rw,flattenArray:()=>N5,flattenEffect:()=>_5,flattenIterable:()=>H5,flattenTake:()=>J5,forever:()=>W5,fromArray:()=>fd,fromArrayEffect:()=>x5,fromArrays:()=>y5,fromAsyncIterable:()=>E5,fromChannel:()=>O,fromEffect:()=>ag,fromEffectDrain:()=>aN,fromEffectRepeat:()=>cN,fromEffectSchedule:()=>a5,fromEventListener:()=>T5,fromIterable:()=>Ma,fromIterableEffect:()=>h5,fromIterableEffectRepeat:()=>g5,fromIteratorSucceed:()=>m5,fromPubSub:()=>b5,fromPubSubTake:()=>lN,fromPull:()=>ao,fromQueue:()=>mu,fromReadableStream:()=>S5,fromSchedule:()=>I5,fromSubscription:()=>w5,groupAdjacentBy:()=>aZ,groupBy:()=>sZ,groupByKey:()=>iZ,grouped:()=>rZ,groupedWithin:()=>oZ,haltWhen:()=>AZ,ignore:()=>_V,ignoreCause:()=>MV,interleave:()=>TZ,interleaveWith:()=>DN,interruptWhen:()=>vZ,intersperse:()=>UN,intersperseAffixes:()=>wZ,isStream:()=>Ie,iterate:()=>A5,let:()=>UZ,limitBytes:()=>DV,make:()=>u5,map:()=>nr,mapAccum:()=>GV,mapAccumArray:()=>Pa,mapAccumArrayEffect:()=>ZV,mapAccumEffect:()=>VV,mapArray:()=>pN,mapArrayEffect:()=>M5,mapBoth:()=>R5,mapEffect:()=>ld,mapError:()=>IN,merge:()=>_w,mergeAll:()=>Y5,mergeEffect:()=>cg,mergeLeft:()=>V5,mergeResult:()=>G5,mergeRight:()=>Z5,mkArrayBuffer:()=>VZ,mkString:()=>GZ,mkUint8Array:()=>ZZ,never:()=>O5,onEnd:()=>OZ,onError:()=>CZ,onExit:()=>NN,onFirst:()=>$N,onStart:()=>LN,orDie:()=>RV,orElseIfEmpty:()=>OV,orElseSucceed:()=>kV,paginate:()=>v5,partition:()=>gV,partitionEffect:()=>hV,partitionQueue:()=>Fw,peel:()=>yV,pipeThrough:()=>hZ,pipeThroughChannel:()=>pZ,pipeThroughChannelOrFail:()=>mZ,prepend:()=>K5,provide:()=>jN,provideContext:()=>RZ,provideService:()=>_Z,provideServiceEffect:()=>Nw,race:()=>fV,raceAll:()=>xN,range:()=>C5,rechunk:()=>kN,repeat:()=>j5,repeatElements:()=>z5,result:()=>P5,retry:()=>PV,run:()=>qN,runCollect:()=>zN,runCount:()=>$Z,runDrain:()=>fg,runFold:()=>BZ,runFoldEffect:()=>qZ,runForEach:()=>HZ,runForEachArray:()=>WN,runForEachWhile:()=>JZ,runHead:()=>zZ,runIntoPubSub:()=>t8,runIntoQueue:()=>o8,runLast:()=>WZ,runSum:()=>jZ,scan:()=>YV,scanEffect:()=>QV,schedule:()=>B5,scoped:()=>k5,service:()=>s5,serviceOption:()=>i5,share:()=>dZ,sliding:()=>WV,slidingSize:()=>RN,split:()=>HV,splitLines:()=>IZ,succeed:()=>pu,suspend:()=>tr,switchMap:()=>D5,sync:()=>f5,take:()=>UV,takeRight:()=>NV,takeUntil:()=>wN,takeUntilEffect:()=>TN,takeWhile:()=>vN,takeWhileEffect:()=>$V,takeWhileFilter:()=>LV,tap:()=>mN,tapBoth:()=>F5,tapCause:()=>EV,tapError:()=>bN,tapSink:()=>U5,throttle:()=>nZ,throttleEffect:()=>_N,tick:()=>c5,timeout:()=>q5,timeoutOrElse:()=>hN,toAsyncIterable:()=>e8,toAsyncIterableEffect:()=>XZ,toAsyncIterableWith:()=>$w,toChannel:()=>Or,toPubSub:()=>n8,toPubSubTake:()=>HN,toPull:()=>KZ,toQueue:()=>r8,toReadableStream:()=>YZ,toReadableStreamEffect:()=>QZ,toReadableStreamWith:()=>Lw,transduce:()=>cZ,transformPull:()=>St,transformPullBracket:()=>kw,unfold:()=>dN,unwrap:()=>ii,updateContext:()=>BN,updateService:()=>MZ,when:()=>xV,withExecutionPlan:()=>FV,withSpan:()=>PZ,zip:()=>eV,zipFlatten:()=>rV,zipLatest:()=>cV,zipLatestAll:()=>Pw,zipLatestWith:()=>uV,zipLeft:()=>tV,zipRight:()=>nV,zipWith:()=>Mw,zipWithArray:()=>ug,zipWithIndex:()=>oV,zipWithNext:()=>sV,zipWithPrevious:()=>iV,zipWithPreviousAndNext:()=>aV});var ZD=\"~effect/RcMap\",Q6=e=>({[ZD]:ZD,lookup:e.lookup,context:e.context,scope:e.scope,idleTimeToLive:e.idleTimeToLive,capacity:e.capacity,state:{_tag:\"Open\",map:ml()},pipe(){return K(this,arguments)}}),YD=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=Q6({lookup:e.lookup,context:n,scope:r,idleTimeToLive:typeof e.idleTimeToLive==\"function\"?Td(e.idleTimeToLive,mt):Le(mt(e.idleTimeToLive??Ms)),capacity:Math.max(e.capacity??Number.POSITIVE_INFINITY,0)});return At(Eo(r,()=>{if(o.state._tag===\"Closed\")return te;let s=o.state.map;return o.state={_tag:\"Closed\"},qs(s,([,i])=>gr(Fe(i.scope,ut))).pipe(bn(()=>oe(()=>{Qm(s)})))}),o)}),vw=l(2,(e,t)=>Hs(n=>{if(e.state._tag===\"Closed\")return vo;let r=e.state,o=th(),s=Xs(r.map,t),i;if(s._tag===\"Some\")i=s.value,i.refCount++;else{if(Number.isFinite(e.capacity)&&u_(e.state.map)>=e.capacity)return P(new ub(`RcMap attempted to exceed capacity of ${e.capacity}`));{i={deferred:qi(),scope:mr(),idleTimeToLive:e.idleTimeToLive(t),finalizer:void 0,fiber:void 0,expiresAt:0,refCount:1},i.finalizer=X6(e,t,i),gl(r.map,t,i);let c=new Map(e.context.mapUnsafe);o.context.mapUnsafe.forEach((u,f)=>{c.set(f,u)}),c.set(xn.key,i.scope),e.lookup(t).pipe(Ao(ho(c)),da(i.scope)).addObserver(u=>pc(i.deferred,u))}}let a=Fr(o.context,xn);return Ht(a,i.finalizer).pipe(yn(n(zi(i.deferred))))}));var X6=(e,t,n)=>Gn(r=>{if(n.refCount--,n.refCount>0)return te;if(e.state._tag===\"Closed\"||!c_(e.state.map,t)||Gu(n.idleTimeToLive))return e.state._tag===\"Open\"&&xl(e.state.map,t),Fe(n.scope,ut);if(!tc(n.idleTimeToLive))return te;let o=r.getRef(_f);return n.expiresAt=o.currentTimeMillisUnsafe()+_n(n.idleTimeToLive),n.fiber?te:(n.fiber=sS(function s(i){let a=o.currentTimeMillisUnsafe(),c=n.expiresAt-a;return c<=0?e.state._tag===\"Closed\"||n.refCount>0?te:(xl(e.state.map,t),i(Fe(n.scope,ut))):T(o.sleep(go(c)),()=>s(i))}).pipe(ea(oe(()=>{n.fiber=void 0})),Ao(r.context),da(e.scope)),te)});var Aw=l(2,(e,t)=>ta(n=>{if(e.state._tag===\"Closed\")return te;let r=Xs(e.state.map,t);if(r._tag===\"None\"||Gu(r.value.idleTimeToLive))return te;let o=r.value;return o.expiresAt=n.currentTimeMillisUnsafe()+_n(o.idleTimeToLive),te}));var e5=\"~effect/RcRef\",Cw={_tag:\"Empty\"},t5={_tag:\"Closed\"},n5={_A:_,_E:_},Ow=class{[e5]=n5;pipe(){return K(this,arguments)}state=Cw;semaphore=Jc(1);acquire;context;scope;idleTimeToLive;constructor(t,n,r,o){this.acquire=t,this.context=n,this.scope=r,this.idleTimeToLive=o}},XD=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=new Ow(e.acquire,n,r,e.idleTimeToLive?mt(e.idleTimeToLive):void 0);return At(Eo(r,()=>{let s=o.state._tag===\"Acquired\"?Fe(o.state.scope,ut):te;return o.state=t5,s}),o)}),r5=e=>Hs(function t(n){switch(e.state._tag){case\"Closed\":return vo;case\"Acquired\":return e.state.refCount++,e.state.fiber?At(ei(e.state.fiber),e.state):x(e.state);case\"Empty\":{let r=mr();return e.semaphore.withPermit(z(()=>e.state._tag!==\"Empty\"?t(n):n(xr(e.acquire,Pt(e.context,xn,r))).pipe($(o=>{let s={_tag:\"Acquired\",value:o,scope:r,fiber:void 0,refCount:1,invalidated:!1};return e.state=s,s}),yr(o=>os(o)?Fe(r,o):te))))}}}),eN=xe(function*(e){let t=e,n=yield*r5(t),r=yield*Qi,o=t.idleTimeToLive!==void 0&&tc(t.idleTimeToLive);return yield*Eo(r,()=>(n.refCount--,n.refCount>0?te:t.idleTimeToLive===void 0?(t.state=Cw,Fe(n.scope,ut)):n.invalidated?Fe(n.scope,ut):o?(n.fiber=Vi(t.idleTimeToLive).pipe(T(()=>t.state._tag===\"Acquired\"&&t.state.refCount===0?(t.state=Cw,Fe(n.scope,ut)):te),ea(oe(()=>{n.fiber=void 0})),Ao(t.context),da(t.scope)),te):te)),n.value});var tN=XD,nN=eN;var sN=\"~effect/Stream\",Ie=e=>M(e,sN),iN=Al,O=JD,ag=e=>O(Eh($(e,Ee))),s5=e=>ag(tS(e)),i5=e=>ag(nS(e)),aN=e=>ao(x(T(e,()=>G()))),cN=e=>ao(x($(e,Ee))),a5=(e,t)=>ao(en(function*(){let n=yield*qr(t),r=yield*To(e,Ft,Ft.defaultValue()),o=!0,s=z(()=>n(r)).pipe(T(i=>To(e,Ft,i)),$(i=>(r=i,Ee(i))));return z(()=>o?(o=!1,x(Ee(r))):s)})),c5=e=>ao(oe(()=>{let t=!0,n=x(Ee(void 0)),r=Vb(n,e);return z(()=>t?(t=!1,n):r)})),ao=e=>O(Nt(e)),St=(e,t)=>O(Ce((n,r)=>T(Mo(e.channel,r),o=>t(o,r)))),kw=(e,t)=>O(to((n,r,o)=>T(Mo(e.channel,r),s=>t(s,r,o)))),Or=e=>e.channel,uN=(e,t)=>O(D_(e,t)),ai=O(Kc),pu=e=>O(Sh(Ee(e))),u5=(...e)=>fd(e),f5=e=>O(L_(()=>Ee(e()))),tr=e=>O(bh(()=>e().channel)),cd=e=>O(_o(e)),l5=e=>O(j_(e)),fN=e=>O(Cl(e)),d5=e=>O(nI(e)),p5=e=>O(B_(e)),m5=(e,t)=>O(tI(()=>e,t)),Ma=(e,t)=>Array.isArray(e)&&t?.chunkSize===void 0?fd(e):O(N_(e,t?.chunkSize)),h5=e=>ii($(e,Ma)),g5=e=>hu(cN(e),Ma),fd=e=>Me(e)?O(Sh(e)):ai,x5=e=>ii($(e,fd)),y5=(...e)=>O(eI(cx(e,Me))),mu=e=>O(z_(e)),b5=e=>O(W_(e)),lN=e=>O(H_(e)),S5=e=>O(J_(e)),E5=(e,t)=>O(K_(e,t)),I5=e=>ao($(gc(e),t=>Ge($(t(void 0),Ee),()=>G()))),w5=e=>O(oI(e)),T5=(e,t,n)=>uN(r=>{function o(s){A_(r,s)}return Xi(oe(()=>e.addEventListener(t,o,n)),()=>oe(()=>e.removeEventListener(t,o,n)))},{bufferSize:typeof n==\"object\"?n.bufferSize:void 0}),dN=(e,t)=>ao(oe(()=>{let n=e;return T(z(()=>t(n)),r=>r===void 0?G():(n=r[1],x(Ee(r[0]))))})),v5=(e,t)=>ao(oe(()=>{let n=e,r=!1;return z(function o(){return r?G():T(t(n),([s,i])=>(ae(i)?r=!0:n=i.value,Me(s)?x(s):o()))})})),A5=(e,t)=>dN(e,n=>x([n,t(n)])),C5=(e,t,n=Al)=>e>t?ai:ao(oe(()=>{let r=Math.max(1,n),o=e,s=!1;return z(()=>{if(s)return G();let i=t-o+1;if(i>r){let c=rx(o,o+r-1);return o+=r,x(c)}let a=rx(o,o+i-1);return s=!0,x(a)})})),O5=O($_),ii=e=>O(xs($(e,Or))),k5=e=>O(IM(e.channel)),nr=l(2,(e,t)=>tr(()=>{let n=0;return O(no(e.channel,Pr(r=>t(r,n++))))})),R5=l(2,(e,t)=>e.pipe(nr(t.onSuccess),IN(t.onFailure))),pN=l(2,(e,t)=>O(no(e.channel,t))),ld=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,Ih(t,n),no(Ee),O)),_5=l(e=>Ie(e[0]),(e,t)=>ld(e,_,t)),M5=l(2,(e,t)=>O(Ih(e.channel,t))),P5=e=>e.pipe(nr(se),dd(t=>pu(re(t)))),mN=l(e=>Ie(e[0]),(e,t,n)=>ld(e,r=>At(t(r),r),n)),F5=l(2,(e,t)=>e.pipe(bN(t.onError),mN(t.onElement,{concurrency:t.concurrency}))),U5=l(2,(e,t)=>kw(e,xe(function*(n,r,o){let s=Yn(),i=Yn(),a,c,u=!1,f=!1,d=s.whenOpen(z(()=>{if(a){let m=a;return a=void 0,f||s.closeUnsafe(),At(i.open,m)}return G()}));yield*z(()=>t.transform(d,o)).pipe(m=>oS(m,g=>(u=!0,os(g)&&(c=g.cause),i.open),!0),yt(o));let p=n.pipe(T(m=>(a=m,i.closeUnsafe(),s.openUnsafe(),At(i.await,m))),Ge(()=>(f=!0,i.closeUnsafe(),s.openUnsafe(),T(i.await,()=>G()))));return z(()=>c?tt(c):u?n:p)}))),hu=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,Z_(r=>t(r).channel,n),O)),D5=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,bM(r=>t(r).channel,n),O)),Rw=l(e=>Ie(e[0]),(e,t)=>hu(e,_,t)),N5=e=>O(rn(e.channel)),L5=e=>O(X_(e.channel)),$5=l(2,(e,t)=>cg(e,fg(t))),j5=l(2,(e,t)=>O(eM(e.channel,t))),B5=l(2,(e,t)=>e.channel.pipe(rn,tM(t),no(Ee),O)),q5=l(2,(e,t)=>hN(e,{duration:t,orElse:()=>ai})),hN=l(2,(e,t)=>{let n=mt(t.duration);if(!tc(n))return e;if(Gu(n))return tr(t.orElse);let r=Symbol();return yN(tr(()=>{let s=th().getRef(_f),i=_n(n),a,c=Yn(!1);return _w(St(e,(u,f)=>z(()=>(a=s.currentTimeMillisUnsafe()+i,c.openUnsafe(),u)).pipe($(d=>(c.closeUnsafe(),a=void 0,d)),x)),aN(en(function*(){for(;;)if(yield*c.await,!(a===void 0||(yield*Vi(a-s.currentTimeMillisUnsafe()),a===void 0)||a-s.currentTimeMillisUnsafe()>0))return yield*hr(r)})),{haltStrategy:\"left\"})}),o=>o.reasons.find(i=>i._tag===\"Die\"&&i.defect===r)?t.orElse():fN(o))}),z5=l(2,(e,t)=>O(Ce((n,r)=>$(me(rn(e.channel))(n,r),o=>{let s,i=en(function*(){let a=yield*o,c=Ee(a);return s=(yield*gc(t))(a).pipe(At(c),Ge(f=>(s=void 0,i))),c});return z(()=>s??i)})))),W5=e=>O(iI(e.channel)),H5=e=>hu(e,Ma),J5=e=>e.channel.pipe(rn,Q_,O),ud=l(2,(e,t)=>Rw(fd([e,t]))),K5=l(2,(e,t)=>ud(Ma(t),e)),_w=l(e=>Ie(e[0])&&Ie(e[1]),(e,t,n)=>O(Ah(Or(e),Or(t),n))),cg=l(2,(e,t)=>e.channel.pipe(SM(t),O)),G5=l(2,(e,t)=>_w(nr(e,se),nr(t,re))),V5=l(2,(e,t)=>cg(e,fg(t))),Z5=l(2,(e,t)=>cg(t,fg(e))),Y5=l(2,(e,t)=>Rw(Ma(e),t)),Q5=l(2,(e,t)=>gN(e,t,(n,r)=>[n,r])),gN=l(3,(e,t,n)=>hu(e,r=>nr(t,o=>n(r,o)))),Mw=l(3,(e,t,n)=>ug(e,t,X5(n))),X5=e=>(t,n)=>{let r=Math.min(t.length,n.length),o=[];for(let s=0;sO(to(xe(function*(r,o){let s=yield*Mo(e.channel,o),i=yield*Mo(t.channel,o),a=en(function*(){let f=yield*yt(s,o),d=yield*yt(i,o);return yield*h_([f,d])}),c={_tag:\"PullBoth\"};return en(function*(){let[f,d]=c._tag===\"PullBoth\"?yield*a:c._tag===\"PullLeft\"?[yield*s,c.rightArray]:[c.leftArray,yield*i],p=n(f,d);return Me(p[1])?c={_tag:\"PullRight\",leftArray:p[1]}:Me(p[2])?c={_tag:\"PullLeft\",rightArray:p[2]}:c={_tag:\"PullBoth\"},p[0]})})))),eV=l(2,(e,t)=>Mw(e,t,(n,r)=>[n,r])),tV=l(2,(e,t)=>ug(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=n.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),nV=l(2,(e,t)=>ug(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=r.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),rV=l(2,(e,t)=>Mw(e,t,(n,r)=>[...n,r])),oV=e=>nr(e,(t,n)=>[t,n]),sV=e=>Pa(e,R,(t,n)=>{let r=0;t._tag===\"None\"&&(r=1,t=k(n[0]));let o=gt();for(;rPa(e,R,(t,n)=>{let r=gt();for(let o=0;oPa(e,()=>({prev:R(),current:R()}),(t,n)=>{let r=0,o;t.current._tag===\"None\"?(r=1,o=n[0],t.current=k(o)):o=t.current.value;let s=gt();for(;rO(bh(()=>{let t=[],n=new Set,r=Yn();return vh(eI(e.map((o,s)=>o.channel.pipe(rn,Ih(i=>(t[s]=i,n.has(s)?x(Ee(t.slice())):(n.add(s),n.sizePw(e,t)),uV=l(3,(e,t,n)=>nr(Pw(e,t),([r,o])=>n(r,o))),xN=(...e)=>O(Ce((t,n)=>oe(()=>{let r,o=Zb(e.map(s=>{let i=vt(n);return Mo(s.channel,i).pipe(T(a=>Kb(x(a),a)),yr(a=>a._tag===\"Success\"?r?Fe(i,a):(r=a.value[0],te):Fe(i,a)),$(([,a])=>a))}));return z(()=>r??o)}))),fV=l(2,(e,t)=>xN(e,t)),lV=l(2,(e,t)=>O(rM(Or(e),t))),dV=l(2,(e,t)=>O(oM(Or(e),t))),pV=l(2,(e,t)=>O(sM(Or(e),t))),mV=l(2,(e,t)=>O(iM(Or(e),t))),Fw=l(e=>Ie(e[0]),xe(function*(e,t,n){let r=yield*Qi,o=yield*Mo(e.channel,r),s=n?.capacity===\"unbounded\"?void 0:n?.capacity??iN,i=yield*wr({capacity:s}),a=yield*wr({capacity:s});return yield*en(function*(){for(;;){let c=yield*o,u=[],f=[];for(let p=0;p0){let p=yh(i,f);p.length>0&&(d=yield*wc(ha(i,p)))}if(u.length>0){let p=yh(a,u);p.length>0&&(yield*ha(a,p))}d&&(yield*Bc(d))}}).pipe(Wr(c=>(ti(i,c),ti(a,c),te)),yt(r)),[i,a]})),hV=l(e=>Ie(e[0]),(e,t,n)=>$(Fw(ld(e,r=>t(r),n),r=>r,n),([r,o])=>[mu(r),mu(o)])),gV=l(e=>Ie(e[0]),(e,t,n)=>$(Fw(e,t,{capacity:n?.bufferSize??16}),([r,o])=>[mu(o),mu(r)])),xV=l(2,(e,t)=>t.pipe($(n=>n?e:ai),ii)),yV=l(2,xe(function*(e,t){let n,r=yield*pI(e.channel),o=ft(r,a=>(n=a,tt(a))),s=ao(x(o)),i=yield*qN(s,t);return n?[i,ai]:(s=ao(x(r)),[i,s])})),bV=l(2,(e,t)=>O(TM(e.channel,t))),SV=l(2,(e,t)=>O(wM(e.channel,t))),yN=l(2,(e,t)=>e.channel.pipe(Vc(n=>t(n).channel),O)),EV=l(2,(e,t)=>e.channel.pipe(Th(t),O)),dd=l(2,(e,t)=>O(Xr(e.channel,n=>t(n).channel)));var bN=l(2,(e,t)=>e.channel.pipe(uM(t),O)),SN=l(e=>Ie(e[0]),(e,t,n,r)=>O(fM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),EN=l(e=>Ie(e[0]),(e,t,n,r)=>O(lM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),IV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Array.isArray(t)?s=>M(s,\"_tag\")&&t.includes(s._tag):$t(t);return SN(e,o,n,r)}),wV=l(e=>Ie(e[0]),(e,t,n)=>{let r;return EN(e,o=>(r??=Object.keys(t),M(o,\"_tag\")&&Xp(o._tag)&&r.includes(o._tag)?se(o):re(o)),o=>t[o._tag](o),n)}),TV=l(e=>Ie(e[0]),(e,t,n,r,o)=>O(dM(Or(e),t,n,(s,i)=>r(s,i).channel,o&&((s,i)=>o(s,i).channel)))),vV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Object.create(null);for(let i of Object.keys(n)){let a=n[i];o[i]=(c,u)=>a(c,u).channel}let s=r&&((i,a)=>r(i,a).channel);return O(pM(e.channel,t,o,s))}),IN=l(2,(e,t)=>O(mM(e.channel,t))),AV=l(3,(e,t,n)=>O(cM(e.channel,t,r=>n(r).channel))),CV=l(3,(e,t,n)=>O(aI(e.channel,t,(r,o)=>n(r,o).channel))),OV=l(2,(e,t)=>O(Y_(e.channel,n=>Or(t())))),kV=l(2,(e,t)=>dd(e,n=>pu(t(n)))),RV=e=>O(hM(e.channel)),_V=l(e=>Ie(e[0]),(e,t)=>O(gM(e.channel,t))),MV=l(e=>Ie(e[0]),(e,t)=>O(xM(e.channel,t))),PV=l(2,(e,t)=>O(yM(e.channel,t))),oN=(e,t)=>ii($(qr(t),n=>{let r=Ft.defaultValue(),o=()=>dd(Nw(e,Ft,oe(()=>r)),s=>ii(Ge($(n(s),i=>(r=i,ii(At(qf,o())))),()=>x(cd(s)))));return o()})),FV=l(e=>Ie(e[0]),(e,t,n)=>tr(()=>{let r=n?.preventFallbackOnPartialStream??!1,o=0,s={attempt:0,stepIndex:0},i=Nw(Vp,oe(()=>(s={attempt:s.attempt+1,stepIndex:o},s))),a=n?.onEvent===void 0?void 0:Bb(n.onEvent,()=>s),c,u=a===void 0?_:p=>NN(LN(p,$(a.begin,m=>{c=m})),m=>z(()=>{if(c===void 0)return te;let g=c;return c=void 0,a.end(g,m)})),f=R(),d=tr(()=>{let p=t.steps[o];if(!p)return cd(ex(f));let m=i(u(jN(e,p.provide))),g=!1;if(_e(f)){let b=f.value,I=!1,w=m;m=tr(()=>I?w:(I=!0,cd(b))),m=oN(m,Bf(p,!1))}else{let b=Bf(p,!0);m=b?oN(m,b):m}return dd(r?$N(m,b=>(g=!0,te)):m,b=>(o++,r&&g?cd(b):(f=k(b),d)))});return d})),UV=l(2,(e,t)=>t<1?ai:wN(e,(n,r)=>r===t-1)),DV=l(3,(e,t,n)=>tr(()=>{let r=BigInt(t),o=BigInt(0),s=!1;return ud(vN(e,i=>{let a=o+BigInt(i.length);return a>r?(s=!0,!1):(o=a,!0)}),tr(()=>s?n():ai))})),NV=l(2,(e,t)=>Pa(e,hs,(n,r)=>(rh(n,r),n.length>t&&Il(n,n.length-t),[n,Ss]),{onHalt(n){return ma(n)}})),wN=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return T(z(()=>i?G():r),c=>{let u=c.findIndex(f=>t(f,s++));if(u>=0){i=!0;let f=c.slice(0,n?.excludeLast?u:u+1);return Me(f)?x(f):G()}return x(c)})}))),TN=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return en(function*(){if(i)return yield*G();let a=yield*r;for(let c=0;cSt(e,(n,r)=>oe(()=>{let o=0,s=!1,i=T(z(()=>s?G():n),a=>{let c=[];for(let u=0;uSt(e,(n,r)=>oe(()=>{let o=!1,s=T(z(()=>o?G():n),i=>{let a=[];for(let c=0;cTN(e,(n,r)=>$(t(n,r),o=>!o),{excludeLast:!0})),Uw=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=0,s=n.pipe(T(i=>o>=t?x(i):(o+=i.length,o<=t?s:x(i.slice(t-o)))));return s}))),jV=l(2,(e,t)=>Uw(AN(e,(n,r)=>!t(n,r)),1)),BV=l(2,(e,t)=>Uw(CN(e,(n,r)=>$(t(n,r),o=>!o)),1)),AN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=T(n,a=>{let c=a.findIndex(u=>!t(u,s++));return c===-1?i:(o=!1,x(a.slice(c)))});return z(()=>o?i:n)}))),qV=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=T(n,i=>{let a=i.findIndex(c=>ie(t(c)));return a===-1?s:(o=!1,x(i.slice(a)))});return z(()=>o?s:n)}))),CN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=en(function*(){for(;;){let a=yield*n;for(let c=0;co?i:n)}))),zV=l(2,(e,t)=>t<=0?e:St(e,(n,r)=>oe(()=>{let o=hs(),s=T(n,i=>{oh(o,i);let a=o.length-t,c=pa(o,a);return qt(c)?x(c):s});return s}))),ON=e=>e.channel.pipe(no(Ee),O),kN=l(2,(e,t)=>(t=Math.max(1,t),St(e,(n,r)=>oe(()=>{let o=gt(),s=0,i,a=!1;return z(function c(){if(a)return G();if(i===void 0)return T(n,u=>o.length===0&&u.length===t?x(u):o.length+u.length{if(o.length===0)return G();let c=o;return a=!0,o=[],x(c)}))})))),WV=l(2,(e,t)=>RN(e,t,1)),RN=l(3,(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=null,i=hs(),a=!1,c=0,u=Zi(r,{onSuccess(f){if(oh(i,f),c>0){let p=i.length;Il(i,c),c=Math.max(0,c-p)}if(i.length=t;)if(t===n)d.push(pa(i,t));else if(d.push(S_(i,t)),t===1&&n<=0)Yr(i);else{let p=i.length;Il(i,n),c=Math.max(0,n-p)}return x(d)},onFailure(f){return a&&Il(i,t-n),i.length===0?tt(f):(s=f,x(Ee(ma(i))))}});return z(()=>s?tt(s):u)}))),HV=l(2,(e,t)=>Pa(e,gt,(n,r)=>{let o=gt();for(let s=0;ssI(rn(e.channel),rn(t.channel),n,r).pipe(no(Ee),O)),KV=l(4,(e,t,n,r)=>O(sI(e.channel,t.channel,n,r))),GV=l(e=>Ie(e[0]),(e,t,n,r)=>O(Gc(e.channel,t,(o,s)=>{let i=gt();for(let a=0;aIe(e[0]),(e,t,n,r)=>O(Gc(e.channel,t,(o,s)=>{let[i,a]=n(o,s);return o=i,[o,Me(a)?Ee(a):Ss]},r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0))),Ss=gt(),VV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(rn,Gc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):gt()]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),ZV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(Gc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):Ss]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),YV=l(3,(e,t,n)=>tr(()=>{let r=!0;return O(Gc(e.channel,Le(t),(o,s)=>{let i=gt();r&&(r=!1,i.push(o));for(let a=0;ae.channel.pipe(rn,aM(t,n),no(Ee),O)),XV=l(2,(e,t)=>St(e,xe(function*(n,r){let o=yield*_f,s=_n(mt(t)),i,a,c=1/0,u=Yn(),f=Yn(),d=Yn();yield*n.pipe(u.whenOpen,T(m=>(f.openUnsafe(),i=m,c=o.currentTimeMillisUnsafe()+s,te)),Ct({disableYield:!0}),Wr(m=>(a=m,c=o.currentTimeMillisUnsafe(),f.openUnsafe(),d.openUnsafe(),te)),yt(r));let p=z(function m(){let g=o.currentTimeMillisUnsafe(),b=c{if(o.currentTimeMillisUnsafe(){if(a){if(i){let m=x(Ee(Vd(i)));return i=void 0,m}return tt(a)}return u.openUnsafe(),f.whenOpen(p)})}))),_N=l(2,(e,t)=>{let n=t.burst??0;return t.strategy===\"enforce\"?eZ(e,t.cost,t.units,t.duration,n):tZ(e,t.cost,t.units,t.duration,n)}),eZ=(e,t,n,r,o)=>St(e,s=>ta(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(T(s,function d(p){return T(t(p),m=>{let g=i.currentTimeMillisUnsafe(),I=(g-f)/a,w=u+I*n,E=w<0?c:Math.min(w,c);return m<=E?(u=E-m,f=g,x(p)):T(s,d)})}))})),tZ=(e,t,n,r,o)=>St(e,s=>ta(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(T(s,d=>T(t(d),p=>{let m=i.currentTimeMillisUnsafe(),b=(m-f)/a,I=u+b*n,E=(I<0?c:Math.min(I,c))-p;if(E>=0)return u=E,f=m,x(d);let h=-E/n,A=Math.max(0,h*a);return A>0?T(Vi(A),()=>(u=E,f=m,x(d))):(u=E,f=m,x(d))})))})),nZ=l(2,(e,t)=>_N(e,{...t,cost:n=>x(t.cost(n))})),rZ=l(2,(e,t)=>ON(kN(e,t))),oZ=l(3,(e,t,n)=>Dw(e,GD(t),Lb(n))),sZ=l(e=>Ie(e[0]),(e,t,n)=>MN(e,xe(function*(r,o,s){for(let i=0;iIe(e[0]),(e,t,n)=>tr(()=>{let r=ml();return MN(e,xe(function*(o,s,i){for(let a=0;akw(e,xe(function*(r,o,s){let i=yield*v_();yield*Ht(o,Qr(i));let a=ml(),c=yield*YD({lookup:u=>Xi(wr({capacity:n?.bufferSize??4096}).pipe(bn(f=>(gl(a,u,f),Qn(i,[u,mu(f)])))),f=>(xl(a,u),C_(f))),idleTimeToLive:n?.idleTimeToLive??Ur}).pipe(ss(s));return yield*zr({while:cn,body:Le(T(r,u=>t(u,c,a))),step:_r}).pipe(ft(u=>Dt(i,u)),yt(o)),Hc(i)})),aZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=gt(),a=n.pipe(T(u=>{for(let f=0;fc?G():a),()=>{c=!0;let u=s;return s=void 0,u&&qt(u)?x(Ee([o,u])):G()})}))),cZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=z(()=>{if(s!==void 0){let c=s;return s=void 0,x(c)}return n}).pipe(Ws(c=>(o=rs(c),G()))),a=$(z(()=>t.transform(i,r)),([c,u])=>(s=u,Ee(c)));return z(()=>o||a)}))),uZ=l(2,(e,t)=>Dw(e,t,Nf)),Dw=l(3,(e,t,n)=>O(to(xe(function*(r,o,s){let i=yield*Mo(e.channel,o),a=Yn(!1),c=Symbol(),u=yield*wr({capacity:0});yield*i.pipe(a.whenOpen,T(h=>(a.closeUnsafe(),Qn(u,h))),Ct,ft(h=>Dt(u,h)),yt(s));let f=R(),d,p=!1,m=yield*gc(n),b=z(function h(){return T(m(f),()=>p?Qn(u,c):h())}).pipe(T(()=>Gi),Ge(()=>G())),I=gs(u).pipe(T(h=>h===c?G():(p=!0,x(h)))),w=z(()=>{if(d!==void 0){let h=d;return d=void 0,p=!0,x(h)}return a.openUnsafe(),I}),E=T(([h,A])=>!p&&u.state._tag===\"Done\"?G():(f=k(h),d=A,x(Ee(h))));return z(()=>u.state._tag===\"Done\"&&d===void 0?u.state.exit:(p=d!==void 0,x(z(()=>t.transform(w,s))))).pipe(T(h=>Ec(E(h),b)))})))),fZ=l(2,xe(function*(e,t){let n=yield*lZ(t),r=new Array(t.n),o=yield*xn;for(let s=0;sFe(i,c)),O)}return yield*xa(e.channel,s=>zc(n,s)).pipe(yr(s=>zc(n,s)),Tc),r})),lZ=e=>Xi(e.capacity===\"unbounded\"?dh(e):e.strategy===\"dropping\"?fh(e):e.strategy===\"sliding\"?lh(e):uh(e),wl),PN=l(2,(e,t)=>$(HN(e,t),lN)),dZ=l(2,(e,t)=>$(tN({acquire:PN(e,t),idleTimeToLive:t.idleTimeToLive}),n=>ii(nN(n)))),pZ=l(2,(e,t)=>O(cI(e.channel,t))),mZ=l(2,(e,t)=>O(uI(e.channel,t))),hZ=l(2,(e,t)=>e.channel.pipe(uI(KD(t)),wh(([n,r])=>r?Sh(r):Kc),O)),gZ=e=>ag(zN(e)),xZ=e=>Pa(e,gt,(t,n)=>{let r=sx(t,n);return[r,[r]]}),yZ=e=>FN(e,B),FN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s;return T(n,function i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));uSt(e,(n,r)=>oe(()=>{let o=!0,s;return T(n,xe(function*i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));uIe(e[0]),(e,t)=>tr(()=>{let n=new TextDecoder(t?.encoding);return nr(e,r=>n.decode(r,{stream:!0}))})),EZ=e=>tr(()=>{let t=new TextEncoder;return nr(e,n=>t.encode(n))}),IZ=e=>e.channel.pipe(cI(EM()),O),UN=l(2,(e,t)=>pN(e,(n,r)=>{let o=r===0?[]:[t],s=n.length-1;for(let i=0;ipu(t.start).pipe(ud(UN(e,t.middle)),ud(pu(t.end)))),TZ=l(2,(e,t)=>DN(e,t,Ma(nT([!0,!1])))),DN=l(3,(e,t,n)=>O(Ce(xe(function*(r,o){let s=yield*me(rn(n.channel))(r,o),i=Symbol(),a=!1,c=!1,u=(yield*me(rn(e.channel))(r,o)).pipe(Ge(()=>(a=!0,x(i)))),f=(yield*me(rn(t.channel))(r,o)).pipe(Ge(()=>(c=!0,x(i))));return en(function*(){for(;;){if(a&&c)return yield*G();let d=yield*s;if(d&&a||!d&&c)continue;let p=yield*d?u:f;if(p!==i)return Ee(p)}})})))),vZ=l(2,(e,t)=>O(vM(e.channel,t))),AZ=l(2,(e,t)=>O(AM(e.channel,t))),NN=l(2,(e,t)=>O(Ol(e.channel,t))),CZ=l(2,(e,t)=>O(CM(e.channel,t))),LN=l(2,(e,t)=>O(OM(e.channel,t))),$N=l(2,(e,t)=>O(fI(e.channel,n=>t(n[0])))),OZ=l(2,(e,t)=>O(kM(e.channel,t))),kZ=l(2,(e,t)=>O(RM(e.channel,t))),jN=l(e=>Ie(e[0]),(e,t,n)=>O(_M(e.channel,t,n))),RZ=l(2,(e,t)=>O(Ch(e.channel,t))),_Z=l(3,(e,t,n)=>O(Oh(e.channel,t,n))),Nw=l(3,(e,t,n)=>O(kh(e.channel,t,n))),BN=l(2,(e,t)=>O(MM(e.channel,t))),MZ=l(3,(e,t,n)=>BN(e,r=>Pt(r,t,n(Xe(r,t))))),PZ=function(){let e=Ie(arguments[0]),t=e?arguments[1]:arguments[0],n=Fs(e?arguments[2]:arguments[1]);if(e){let r=arguments[0];return O(lI(r.channel,t,n))}return r=>O(lI(r.channel,t,n))},FZ=pu({}),UZ=l(3,(e,t,n)=>nr(e,r=>({...r,[t]:n(r)})));var DZ=l(e=>Ie(e[0]),(e,t,n,r)=>hu(e,o=>nr(n(o),s=>({...o,[t]:s})),r)),NZ=l(e=>Ie(e[0]),(e,t,n,r)=>ld(e,o=>$(n(o),s=>({...o,[t]:s})),r)),LZ=l(2,(e,t)=>nr(e,n=>({[t]:n}))),qN=l(2,(e,t)=>rS(n=>Mo(e.channel,n).pipe(T(r=>t.transform(r,n)),$(([r])=>r)))),zN=e=>ya(e.channel,()=>[],(t,n)=>{for(let r=0;rya(e.channel,()=>0,(t,n)=>t+n.length),jZ=e=>ya(e.channel,()=>0,(t,n)=>{for(let r=0;rya(e.channel,t,(r,o)=>{for(let s=0;sNM(e.channel,t,(r,o)=>{let s=0,i=r;return $(zr({while:()=>sn(i,o[s]),step(a){i=a,s++}}),()=>i)})),zZ=e=>$(UM(e.channel),Bt(cT(0))),WZ=e=>$(DM(e.channel),Bt(Vd)),HZ=l(2,(e,t)=>xa(e.channel,n=>{let r=0;return zr({while:()=>rt(n[r++]),step:_r})})),JZ=l(2,(e,t)=>FM(e.channel,n=>{let r=!1,o=0;return $(zr({while:()=>!r&&ot(n[o]),step(s){o++,s||(r=!0)}}),()=>!r)})),WN=l(2,(e,t)=>xa(e.channel,t)),fg=e=>PM(e.channel),KZ=e=>pI(e.channel),GZ=e=>ya(e.channel,()=>\"\",(t,n)=>t+n.join(\"\")),VZ=e=>$(dI(e.channel),t=>t.buffer),ZZ=e=>dI(e.channel),Lw=l(e=>Ie(e[0]),(e,t,n)=>{let r,o,s=Yn(!1);return new ReadableStream({start(i){o=Hf(xr(WN(e,a=>s.whenOpen(oe(()=>{s.closeUnsafe();for(let c=0;c{a._tag===\"Failure\"?i.error(Wp(a.cause)):i.close()})},pull(){return new Promise(i=>{r=i,s.openUnsafe()})},cancel(){if(o)return aS(zs(ei(o)))}},n?.strategy)}),YZ=l(e=>Ie(e[0]),(e,t)=>Lw(e,pn(),t)),QZ=l(e=>Ie(e[0]),(e,t)=>$(Yi(),n=>Lw(e,n,t))),$w=l(2,(e,t)=>({[Symbol.asyncIterator](){let n=cS(t),r=Ao(t),o=mr(),s,i,a,c,u=d=>{if(c)return c;let p=a;return c=n(At(yn(p?ei(p):te,Fe(o,d)),{done:!0,value:void 0})),c},f=async d=>{try{await u(d)}catch(p){await n(uS(\"Suppressed error while closing Stream async iterator\",p))}};return{async next(){if(c)return c;if(i){let m=i.next();if(!m.done)return m;i=void 0}let d=r(s??T(Mo(e.channel,o),m=>(s=m,m)));a=d;let p=await n(m_(d));if(a===d&&(a=void 0),et(p))return i=p.value[Symbol.iterator](),i.next();if(is(p.cause))return u(ut);if(c&&ob(p.cause))return c;throw await f(p),Wp(p.cause)},return(){return u(ut)},async throw(d){throw await f(fb(d)),d}}}})),XZ=e=>$(Yi(),t=>$w(e,t)),e8=e=>$w(e,pn()),t8=l(e=>Ie(e[0]),(e,t,n)=>hI(e.channel,t,n)),n8=l(2,(e,t)=>jM(e.channel,t)),HN=l(2,(e,t)=>BM(e.channel,t)),r8=l(2,(e,t)=>LM(e.channel,t)),o8=l(2,(e,t)=>mI(e.channel,t));var pd=BigInt(1024),Pne=pd*pd*pd*pd*pd;var jw=Vt(\"effect/platform/FileSystem\");var KN=\"~effect/platform/Path\",qw=Vt(\"effect/Path\");function GN(e,t){let n=\"\",r=0,o=-1,s=0,i;for(let a=0;a<=e.length;++a){if(a2){let c=n.lastIndexOf(\"/\");if(c!==n.length-1){c===-1?(n=\"\",r=0):(n=n.slice(0,c),r=n.length-1-n.lastIndexOf(\"/\")),o=a,s=0;continue}}else if(n.length===2||n.length===1){n=\"\",r=0,o=a,s=0;continue}}t&&(n.length>0?n+=\"/..\":n=\"..\",r=2)}else n.length>0?n+=\"/\"+e.slice(o+1,a):n=e.slice(o+1,a),r=a-o-1;o=a,s=0}else i===46&&s!==-1?++s:s=-1}return n}function s8(e,t){let n=t.dir||t.root,r=t.base||(t.name||\"\")+(t.ext||\"\");return n?n===t.root?n+r:n+e+r:r}function i8(e){if(e.protocol!==\"file:\")return P(new du({module:\"Path\",method:\"fromFileUrl\",description:\"URL must be of scheme file\"}));if(e.hostname!==\"\")return P(new du({module:\"Path\",method:\"fromFileUrl\",description:\"Invalid file URL host\"}));let t=e.pathname;for(let n=0;n=-1&&!n;o--){let s;if(o>=0)s=arguments[o];else{let i=globalThis.process;r===void 0&&\"process\"in globalThis&&typeof i==\"object\"&&i!==null&&typeof i.cwd==\"function\"&&(r=i.cwd()),s=r}s.length!==0&&(t=s+\"/\"+t,n=s.charCodeAt(0)===47)}return t=GN(t,!n),n?t.length>0?\"/\"+t:\"/\":t.length>0?t:\".\"},a8=47;function c8(e){let t=new URL(\"file://\"),n=VN(e);return e.charCodeAt(e.length-1)===a8&&n[n.length-1]!==\"/\"&&(n+=\"/\"),t.pathname=m8(n),x(t)}var u8=/%/g,f8=/\\\\/g,l8=/\\n/g,d8=/\\r/g,p8=/\\t/g;function m8(e){return e.includes(\"%\")&&(e=e.replace(u8,\"%25\")),e.includes(\"\\\\\")&&(e=e.replace(f8,\"%5C\")),e.includes(`\n`)&&(e=e.replace(l8,\"%0A\")),e.includes(\"\\r\")&&(e=e.replace(d8,\"%0D\")),e.includes(\"\t\")&&(e=e.replace(p8,\"%09\")),e}var Bw=qw.of({[KN]:KN,resolve:VN,normalize(e){if(e.length===0)return\".\";let t=e.charCodeAt(0)===47,n=e.charCodeAt(e.length-1)===47;return e=GN(e,!t),e.length===0&&!t&&(e=\".\"),e.length>0&&n&&(e+=\"/\"),t?\"/\"+e:e},isAbsolute(e){return e.length>0&&e.charCodeAt(0)===47},join(){if(arguments.length===0)return\".\";let e;for(let t=0;t0&&(e===void 0?e=n:e+=\"/\"+n)}return e===void 0?\".\":Bw.normalize(e)},relative(e,t){if(e===t||(e=Bw.resolve(e),t=Bw.resolve(t),e===t))return\"\";let n=1;for(;nc){if(t.charCodeAt(s+f)===47)return t.slice(s+f+1);if(f===0)return t.slice(s+f)}else o>c&&(e.charCodeAt(n+f)===47?u=f:f===0&&(u=0));break}let p=e.charCodeAt(n+f),m=t.charCodeAt(s+f);if(p!==m)break;p===47&&(u=f)}let d=\"\";for(f=n+u+1;f<=r;++f)(f===r||e.charCodeAt(f)===47)&&(d.length===0?d+=\"..\":d+=\"/..\");return d.length>0?d+t.slice(s+u):(s+=u,t.charCodeAt(s)===47&&++s,t.slice(s))},dirname(e){if(e.length===0)return\".\";let t=e.charCodeAt(0),n=t===47,r=-1,o=!0;for(let s=e.length-1;s>=1;--s)if(t=e.charCodeAt(s),t===47){if(!o){r=s;break}}else o=!1;return r===-1?n?\"/\":\".\":n&&r===1?\"//\":e.slice(0,r)},basename(e,t){let n=0,r=-1,o=!0,s;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return\"\";let i=t.length-1,a=-1;for(s=e.length-1;s>=0;--s){let c=e.charCodeAt(s);if(c===47){if(!o){n=s+1;break}}else a===-1&&(o=!1,a=s+1),i>=0&&(c===t.charCodeAt(i)?--i===-1&&(r=s):(i=-1,r=a))}return n===r?r=a:r===-1&&(r=e.length),e.slice(n,r)}else{for(s=e.length-1;s>=0;--s)if(e.charCodeAt(s)===47){if(!o){n=s+1;break}}else r===-1&&(o=!1,r=s+1);return r===-1?\"\":e.slice(n,r)}},extname(e){let t=-1,n=0,r=-1,o=!0,s=0;for(let i=e.length-1;i>=0;--i){let a=e.charCodeAt(i);if(a===47){if(!o){n=i+1;break}continue}r===-1&&(o=!1,r=i+1),a===46?t===-1?t=i:s!==1&&(s=1):t!==-1&&(s=-1)}return t===-1||r===-1||s===0||s===1&&t===r-1&&t===n+1?\"\":e.slice(t,r)},format:function(t){if(t===null||typeof t!=\"object\")throw new TypeError('The \"pathObject\" argument must be of type Object. Received type '+typeof t);return s8(\"/\",t)},parse(e){let t={root:\"\",dir:\"\",base:\"\",ext:\"\",name:\"\"};if(e.length===0)return t;let n=e.charCodeAt(0),r=n===47,o;r?(t.root=\"/\",o=1):o=0;let s=-1,i=0,a=-1,c=!0,u=e.length-1,f=0;for(;u>=o;--u){if(n=e.charCodeAt(u),n===47){if(!c){i=u+1;break}continue}a===-1&&(c=!1,a=u+1),n===46?s===-1?s=u:f!==1&&(f=1):s!==-1&&(f=-1)}return s===-1||a===-1||f===0||f===1&&s===a-1&&s===i+1?a!==-1&&(i===0&&r?t.base=t.name=e.slice(1,a):t.base=t.name=e.slice(i,a)):(i===0&&r?(t.name=e.slice(1,s),t.base=e.slice(1,a)):(t.name=e.slice(i,s),t.base=e.slice(i,a)),t.ext=e.slice(s,a)),i>0?t.dir=e.slice(0,i-1):r&&(t.dir=\"/\"),t},sep:\"/\",fromFileUrl:i8,toFileUrl:c8,toNamespacedPath:_});function md(e){return{_tag:\"Value\",value:e}}function dg(e,t){return{_tag:\"Record\",keys:e,value:t}}function Hw(e,t){return{_tag:\"Array\",length:e,value:t}}var lg=class extends yo(\"SourceError\"){},Fa=Ae(\"effect/ConfigProvider\",{defaultValue:()=>n1()}),h8={...ln,toJSON(){return{_id:\"ConfigProvider\"}}},g8=e=>e;function YN(e,t){let n=Object.create(h8);return n.load=e,n.mapInput=t,n}function QN(e,t){return YN(n=>e(t(n)),n=>QN(e,Td(t,n)))}function XN(e,t){return YN(n=>T(e.load(n),r=>r!==void 0?x(r):t.load(n)),n=>XN(e.mapInput(n),t.mapInput(n)))}function pg(e){return QN(e,g8)}var Ww=l(2,(e,t)=>XN(e,t)),Jw=l(2,(e,t)=>e.mapInput(t)),x8=Jw(e=>e.map(t=>typeof t==\"number\"?t:FO(t))),y8=l(2,(e,t)=>{let n=typeof t==\"string\"?[t]:t;return Jw(e,r=>[...n,...r])}),b8=e=>wo(e)?gb(Fa)(e):iO(Fa)(e),S8=(e,t)=>gb(Fa)(en(function*(){let n=yield*Fa,r=wo(e)?yield*e:e;return t?.asPrimary?Ww(r,n):Ww(n,r)}));function E8(e,t){let n=t?.preserveEmptyStrings===!0;return pg(r=>x(I8(e,r,n)))}function I8(e,t,n){let r=e;for(let o of t){if(r==null)return;if(Array.isArray(r)){if(typeof o!=\"number\"||!Number.isInteger(o)||o<0||o>=r.length)return;r=r[o];continue}if(wt(r)){if(typeof o!=\"string\"||!Object.hasOwn(r,o))return;r=r[o];continue}return}return w8(r,n)}function w8(e,t){if(e!=null)return typeof e==\"string\"?e1(e,t):typeof e==\"number\"||typeof e==\"boolean\"||typeof e==\"bigint\"?md(String(e)):Array.isArray(e)?Hw(e.length):wt(e)?dg(new Set(Object.keys(e))):md(N(e))}function e1(e,t){let n=t1(e,t);return n===void 0?void 0:md(n)}function t1(e,t){return e===\"\"&&!t?void 0:e}function Kw(e,t){let n=t?.preserveEmptyStrings===!0,r=T8(e);return pg(o=>x(A8(r,e,o,n)))}function n1(e){let t=e?.env??{...globalThis.process?.env,...import.meta?.env};return Kw(t,{preserveEmptyStrings:e?.preserveEmptyStrings})}function T8(e){let t={};for(let[n,r]of Object.entries(e)){if(r===void 0)continue;let o=n.split(\"_\"),s=t;for(let i of o){let a=s.children??=Object.create(null);s=a[i]??={}}}return t}var v8=/^(0|[1-9][0-9]*)$/;function A8(e,t,n,r){let o=n.map(String).join(\"_\"),s=t1(Object.hasOwn(t,o)?t[o]:void 0,r),i=C8(e,n),a=i?.children?Object.keys(i.children):[];if(a.length===0)return s===void 0?void 0:md(s);if(a.every(u=>v8.test(u))){let u=Math.max(...a.map(f=>parseInt(f,10)))+1;return Hw(u,s)}return dg(new Set(a),s)}function C8(e,t){if(t.length===0)return e;let n=e;for(let r of t)if(n=n?.children?.[String(r)],!n)return;return n}function r1(e,t){let n=k8(e);return t?.expandVariables&&(n=R8(n)),Kw(n,{preserveEmptyStrings:t?.preserveEmptyStrings})}var O8=/(?:^|^)\\s*(?:export\\s+)?([\\w.-]+)(?:\\s*=\\s*?|:\\s+?)(\\s*'(?:\\\\'|[^'])*'|\\s*\"(?:\\\\\"|[^\"])*\"|\\s*`(?:\\\\`|[^`])*`|[^#\\r\\n]+)?\\s*(?:#.*)?(?:$|$)/mg;function k8(e){let t=Object.create(null);e=e.replace(/\\r\\n?/gm,`\n`);let n;for(;(n=O8.exec(e))!=null;){let r=n[1],o=n[2]||\"\";o=o.trim();let s=o[0];o=o.replace(/^(['\"`])([\\s\\S]*)\\1$/gm,\"$2\"),s==='\"'&&(o=o.replace(/\\\\n/g,`\n`),o=o.replace(/\\\\r/g,\"\\r\")),t[r]=o}return t}function R8(e){let t=Object.create(null);for(let n of Object.keys(e))t[n]=o1(e[n],e).replace(/\\\\\\$/g,\"$\");return t}function o1(e,t){let n=_8(e,/(?!(?<=\\\\))\\$/g);if(n===-1)return e;let r=e.slice(n),o=/((?!(?<=\\\\))\\${?([\\w]+)(?::-([^}\\\\]*))?}?)/,s=r.match(o);if(s!==null){let[i,a,c,u]=s,f=Object.hasOwn(t,c)&&t[c]!==\"\"?t[c]:u??\"\";return o1(e.replace(a,f),t)}return e}function _8(e,t){let n=Array.from(e.matchAll(t));return n.length>0?n.slice(-1)[0].index:-1}var M8=xe(function*(e){let n=yield*(yield*jw).readFileString(e?.path??\".env\");return r1(n,e)}),P8=xe(function*(e){let t=yield*qw,n=yield*jw,r=e?.rootPath??\"/\",o=e?.preserveEmptyStrings===!0;return pg(s=>{let i=t.join(r,...s.map(String)),a=n.readFileString(i).pipe($(u=>e1(u.trim(),o))),c=n.readDirectory(i).pipe($(u=>dg(new Set(u.map(f=>t.basename(f))))));return a.pipe(Ws(u=>c.pipe(Ws(f=>zw(u)&&zw(f)?x(void 0):P(zw(u)?f:u)))),fm(u=>new lg({message:`Failed to read file at ${t.join(r,...s.map(String))}`,cause:u})))})}),zw=e=>e.reason._tag===\"NotFound\";var s1=[\"All\",\"Fatal\",\"Error\",\"Warn\",\"Info\",\"Debug\",\"Trace\",\"None\"];var Gw=\"~effect/Config\",u1=e=>M(e,Gw),xu=class{_tag=\"ConfigError\";name=\"ConfigError\";cause;constructor(t){this.cause=t}get message(){return this.cause.toString()}toString(){return`ConfigError(${this.message})`}},F8={...Xd({label:\"Config\",evaluate(e){return this.parse(e.getRef(Fa))}}),[Gw]:Gw,toJSON(){return{_id:\"Config\"}}};function No(e){let t=Object.create(F8);return t.evaluator=e,t.parse=n=>e(n,[]).pipe(fs(r=>r.error),lt(r=>r._tag===\"Resolved\"?x(r.value):P(r.error))),t}var Es=(e,t,n)=>e.evaluator(t,n),ci=(e,t)=>({_tag:\"Resolved\",value:e,hasInput:t}),U8=e=>({_tag:\"Absent\",error:e}),Lo=(e,t)=>({error:e,hasInput:t}),D8=e=>$t(e,\"SourceError\"),a1=(e,t)=>e.pipe(zf(n=>D8(n)?P(Lo(new xu(n),t)):hr(n))),N8=(e,t)=>t?e.pipe(fs(n=>Lo(n.error,!0)),lt(n=>n._tag===\"Resolved\"?x(ci(n.value,!0)):P(Lo(n.error,!0)))):e,f1=l(2,(e,t)=>No((n,r)=>$(Es(e,n,r),o=>o._tag===\"Resolved\"?ci(t(o.value),o.hasInput):o))),L8=l(2,(e,t)=>No((n,r)=>T(Es(e,n,r),o=>o._tag===\"Resolved\"?t(o.value).pipe(br(s=>ci(s,o.hasInput)),fs(s=>Lo(s,o.hasInput))):x(o)))),$8=l(2,(e,t)=>No((n,r)=>eS(Es(e,n,r),{onFailure:o=>N8(Es(t(o.error),n,r),o.hasInput),onSuccess:o=>o._tag===\"Absent\"?Es(t(o.error),n,r):x(o)})));function l1(e){let t=Array.isArray(e)?e:Symbol.iterator in e?[...e]:e;return Array.isArray(t)?No((n,r)=>lt(om(t.map(o=>cm(Es(o,n,r)))),j8)):No((n,r)=>lt(om(Wu(t,o=>cm(Es(o,n,r)))),B8))}var j8=e=>{let t=[],n,r,o=!1;for(let s of e){if(ie(s)){n??=s.failure,o=o||s.failure.hasInput;continue}let i=s.success;i._tag===\"Absent\"?r??=i:(t.push(i.value),o=o||i.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},B8=e=>{let t={},n,r,o=!1;for(let s in e){let i=e[s];if(ie(i)){n??=i.failure,o=o||i.failure.hasInput;continue}let a=i.success;a._tag===\"Absent\"?r??=a:(F(t,s,a.value),o=o||a.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},d1=l(2,(e,t)=>No((n,r)=>br(Es(e,n,r),o=>o._tag===\"Absent\"?ci(t,!1):o))),q8=e=>e.pipe(f1(k),d1(R())),p1=e=>u1(e)?e:l1(Wu(e,t=>p1(t))),z8=()=>\"\",m1=(e,t)=>e.load(t).pipe(Gb,br(n=>({provider:e,path:t,node:n,toString:z8}))),c1=(e,t)=>m1(e.provider,[...e.path,t]),Vw=e=>e?.value,mg=(e,t)=>$c(Kr,e,new Te(tn(n=>t(n)),Sn())),h1=e=>{switch(e._tag){case\"Union\":return e.types.every(h1);case\"Objects\":case\"Arrays\":case\"Suspend\":return!1;default:return!0}},Zw=(e,t)=>{switch(e._tag){case\"Objects\":return t?._tag===\"Record\";case\"Arrays\":return t?._tag===\"Array\";case\"Union\":return e.types.some(n=>Zw(n,t));case\"Suspend\":return Zw(e.thunk(),t);default:return Vw(t)!==void 0}},W8=It(e=>{let t=new WeakSet,n=BR(r=>{switch(t.add(r),r._tag){case\"Objects\":{let o=r.indexSignatures.map(i=>Ia(i.parameter)),s=xe(function*(i){if(i.node?._tag!==\"Record\")return;let a=i.node,c=new Set;for(let f of r.propertySignatures)typeof f.name==\"string\"&&c.add(f.name);if(o.length>0)for(let f of a.keys)o.some(d=>d(f))&&c.add(f);let u={};for(let f of c){let d=yield*c1(i,f);d.node!==void 0&&F(u,f,d)}return u});return mg(r.recur(n,i=>i),s)}case\"Arrays\":{let o=xe(function*(s){if(s.node?._tag!==\"Array\")return;let i=[];for(let a=0;ax(Vw(o.node))):r.recur(n);case\"Suspend\":{let o=r.thunk();return t.has(o)||n(o),r.recur(n)}case\"Declaration\":case\"Any\":throw new globalThis.Error(\"Config.schema does not support opaque StringTree encodings\",{cause:r});default:return mg(r,o=>x(Vw(o.node)))}});return n(e)});function vn(e,t){let n=lu(e),r=nn(n.ast),o=Ze(j(W8(n.ast))),s=typeof t==\"string\"?[t]:t??[];return No((i,a)=>{let c=[...a,...s];return a1(m1(i,c),!1).pipe(lt(u=>{let f=Zw(r,u.node);return a1(o(u).pipe(br(d=>ci(d,f)),vc(d=>{let p=new xu(new bs(c.length>0?new Ve(c,d):d));return f?P(Lo(p,!0)):x(U8(p))})),f)}))})}var g1=io([\"true\",\"yes\",\"on\",\"1\",\"y\"]),x1=io([\"false\",\"no\",\"off\",\"0\",\"n\"]),y1=io([...g1.literals,...x1.literals]).pipe(ne(ng,We({decode:e=>e===\"true\"||e===\"yes\"||e===\"on\"||e===\"1\"||e===\"y\",encode:e=>e?\"true\":\"false\"}))),b1=Uo.check(fu({minimum:1,maximum:65535})),S1=io(s1),H8=(e,t,n)=>{let r=Gl(e,t),o=Vk(n),s=X.pipe(ne(lu(r),{decode:o.decode,encode:Sn({strict:!1}).compose(o.encode)}));return st([r,s])},J8=(e,t)=>{let n=Ye(e),r=t?.separator??\",\",o=X.pipe(ne(lu(n),{decode:Pk(t),encode:Sn({strict:!1}).compose(ue(s=>s.join(r)))}));return st([o,n])};function K8(e){return No(()=>P(Lo(new xu(e),!1)))}function G8(e){return No(()=>x(ci(e,!1)))}function V8(e){return vn(X,e)}function Z8(e){return vn(pw,e)}function Y8(e){return vn(uu,e)}function Q8(e){return vn(Xn,e)}function X8(e){return vn(Uo,e)}function eY(e,t){return vn(ze(e),t)}function tY(e,t){return vn(io(e),t)}function nY(e){return vn(y1,e)}function rY(e){return vn(hw,e)}function oY(e){return vn(b1,e)}function sY(e){return vn(S1,e)}function iY(e){return vn(td(X),e)}function aY(e){return vn(nd,e)}function cY(e){return vn(er,e)}var uY=l(2,(e,t)=>No((n,r)=>Es(e,n,[...r,t])));var Ua={};uo(Ua,{Console:()=>E1,assert:()=>fY,clear:()=>lY,consoleWith:()=>Ot,count:()=>dY,countReset:()=>pY,debug:()=>mY,dir:()=>hY,dirxml:()=>gY,error:()=>xY,group:()=>yY,info:()=>bY,log:()=>SY,table:()=>EY,time:()=>IY,timeLog:()=>wY,trace:()=>TY,warn:()=>vY,withGroup:()=>AY,withTime:()=>CY});var E1=Vy,Ot=e=>Y(t=>e(t.getRef(E1))),fY=(e,...t)=>Ot(n=>L(()=>{n.assert(e,...t)})),lY=Ot(e=>L(()=>{e.clear()})),dY=e=>Ot(t=>L(()=>{t.count(e)})),pY=e=>Ot(t=>L(()=>{t.countReset(e)})),mY=(...e)=>Ot(t=>L(()=>{t.debug(...e)})),hY=(e,t)=>Ot(n=>L(()=>{n.dir(e,t)})),gY=(...e)=>Ot(t=>L(()=>{t.dirxml(...e)})),xY=(...e)=>Ot(t=>L(()=>{t.error(...e)})),yY=e=>Ot(t=>Li(L(()=>{e?.collapsed?t.groupCollapsed(e.label):t.group(e?.label)}),()=>L(()=>{t.groupEnd()}))),bY=(...e)=>Ot(t=>L(()=>{t.info(...e)})),SY=(...e)=>Ot(t=>L(()=>{t.log(...e)})),EY=(e,t)=>Ot(n=>L(()=>{n.table(e,t)})),IY=e=>Ot(t=>Li(L(()=>{t.time(e)}),()=>L(()=>{t.timeEnd(e)}))),wY=(e,...t)=>Ot(n=>L(()=>{n.timeLog(e,...t)})),TY=(...e)=>Ot(t=>L(()=>{t.trace(...e)})),vY=(...e)=>Ot(t=>L(()=>{t.warn(...e)})),AY=l(e=>ee(e[0]),(e,t)=>Ot(n=>yf(L(()=>{t?.collapsed?n.groupCollapsed(t.label):n.group(t?.label)}),()=>e,()=>L(()=>{n.groupEnd()})))),CY=l(e=>ee(e[0]),(e,t)=>Ot(n=>yf(L(()=>{n.time(t)}),()=>e,()=>L(()=>{n.timeEnd(t)}))));var Nn={OK:200,Unauthorized:401,NotFound:404,InternalServerError:500,ServiceUnavailable:503},Yw={[Nn.OK]:\"OK\",[Nn.Unauthorized]:\"Unauthorized\",[Nn.NotFound]:\"Not Found\",[Nn.InternalServerError]:\"Internal Server Error\",[Nn.ServiceUnavailable]:\"Service Unavailable\"},OY=e=>{let t=e.startsWith(\"/\"),n=[];for(let o of e.split(\"/\"))if(!(o===\"\"||o===\".\")){if(o===\"..\"){n.length>0&&n.at(-1)!==\"..\"?n.pop():t||n.push(\"..\");continue}n.push(o)}let r=n.join(\"/\");return t?`/${r}`:r||\".\"},ui=(...e)=>OY(e.filter(Boolean).join(\"/\")),yu=e=>{if(e.length===0)return\".\";let t=e.length;for(;t>1&&e[t-1]===\"/\";)t-=1;let n=e.slice(0,t),r=n.lastIndexOf(\"/\");return r<0?\".\":r===0?\"/\":n.slice(0,r)},I1=e=>{if(!e.startsWith(\"/\"))throw new TypeError(\"Path must be absolute\");let t=new URL(\"file:///\");return t.pathname=e.replace(/%/g,\"%25\").replace(/\\\\/g,\"%5C\"),t};var kY=/^[A-Za-z0-9_-]+$/u,w1=/[*?[{]/u,hg=(e,t)=>t===e||t.startsWith(`${e.replace(/\\/+$/u,\"\")}/`),bu=class extends bo.TaggedError(\"FunctionFileSystemError\"){},kr=(e,t)=>e.lstat(t).pipe(D.catch(()=>D.undefined)),Da=(e,t,n)=>D.all([e.realPath(t),e.realPath(n)],{concurrency:2}).pipe(D.map(([r,o])=>hg(r,o)),D.orElseSucceed(()=>!1)),T1=(e,t,n)=>D.gen(function*(){let r=yield*kr(e,n);if(r===void 0)return!0;if(r.isSymbolicLink||!(yield*Da(e,t,n)))return!1;if(!r.isDirectory)return!0;let o=yield*e.readDirectory(n).pipe(D.catch(()=>D.undefined));if(o===void 0)return!1;for(let s of o)if(!(yield*T1(e,t,ui(n,s))))return!1;return!0}),hd=(e,t)=>t.length===0?\"\":t.startsWith(\"/\")?t:ui(e,t),Qw=[\"deno.json\",\"deno.jsonc\"],v1=e=>Qw.includes(e.slice(e.lastIndexOf(\"/\")+1)),RY=(e,t,n)=>D.gen(function*(){let r=yu(ui(n));for(;hg(t,r);){for(let s of Qw){let i=ui(r,s),a=yield*kr(e,i);if(a!==void 0)return a.isFile&&!a.isSymbolicLink?i:void 0}let o=yu(r);if(o===r)break;r=o}}),_Y=(e,t,n)=>D.all([e.realPath(t),e.realPath(n)],{concurrency:2}).pipe(D.map(([r,o])=>r===o),D.orElseSucceed(()=>!1)),A1=D.fn(\"Functions.resolveFunctionConfig\")(function*(e){let{root:t,slug:n,overrides:r,fs:o}=e;if(!t.startsWith(\"/\")||!kY.test(n)||n===\"_shared\"||(yield*kr(o,t))===void 0)return;let i=yield*o.realPath(t).pipe(D.orElseSucceed(()=>\"\"));if(!i.startsWith(\"/\"))return;let a=yield*kr(o,i);if(a===void 0||!a.isDirectory)return;let c=e.filesRoot===void 0?i:yield*o.realPath(e.filesRoot).pipe(D.orElseSucceed(()=>\"\"));if(!c.startsWith(\"/\")||!hg(c,i))return;let u=r.$default,f=r[n],d={...u,...f};if(d.enabled===!1)return;let p=ui(i,n),m=d?.entrypointPath&&d.entrypointPath.length>0?d.entrypointPath:d.entrypoint&&d.entrypoint.length>0?d.entrypoint:\"index.ts\";if(!m.startsWith(\"/\")){let S=yield*kr(o,p);if(S===void 0||!S.isDirectory||!(yield*Da(o,i,p)))return}let g=hd(p,m);if(!(yield*Da(o,c,g)))return;let b=yield*kr(o,g);if(b===void 0||!b.isFile||b.isSymbolicLink)return;let I=f?.importMapPath??f?.import_map,w=u?.importMapRoot??u?.import_map_root,E=I!==void 0?hd(p,I):w!==void 0?hd(i,w):hd(p,\"\");if(E.length>0){if(!(yield*Da(o,c,E)))return;let S=yield*kr(o,E);if(S===void 0||!S.isFile||S.isSymbolicLink)return}else for(let S of Qw){let v=ui(p,S),C=yield*kr(o,v);if(C!==void 0){if(!C.isFile||C.isSymbolicLink||!(yield*Da(o,c,v)))return;E=v;break}}let h=E.length===0?void 0:yield*RY(o,c,g),A=h!==void 0&&(yield*_Y(o,h,E)),y=(d.staticFiles??d.static_files??[]).map(S=>hd(p,S));for(let S of y){if(!hg(c,S))return;let v=S.search(w1),C=v<0?S:S.slice(0,v),V=v<0?yu(S):C.slice(0,Math.max(0,C.lastIndexOf(\"/\")))||i;if(!(yield*T1(o,c,V)))return;if(!w1.test(S)){let ce=yield*kr(o,S);if(ce!==void 0&&(!(yield*Da(o,c,S))||ce.isSymbolicLink))return}}return{entrypointPath:g,importMapPath:E,importMapDiscoveredByRuntime:A,staticFiles:y,verifyJWT:d.verifyJWT??d.verify_jwt??!0,env:d.env}}),MY=e=>ui(yu(e.entrypointPath),\"package.json\"),C1=e=>{let t=new Map,n=new Map;return(r,o)=>{let s=n.get(r);if(s!==void 0)return s;let i=yu(o.entrypointPath),a=t.get(i),c=a===void 0||a===r?i:e();return a===void 0&&t.set(i,r),n.set(r,c),c}},O1=D.fn(\"Functions.packageJsonContainedFor\")(function*(e){if(!e.root.startsWith(\"/\"))return!1;let t=yield*kr(e.fs,e.root);if(t===void 0||!t.isDirectory&&!t.isSymbolicLink)return!1;let n=yield*e.fs.realPath(e.root).pipe(D.orElseSucceed(()=>\"\"));if(!n.startsWith(\"/\"))return!1;let r=yield*kr(e.fs,n);if(r===void 0||!r.isDirectory)return!1;let o=MY(e.config),s=yield*kr(e.fs,o);return s===void 0||!s.isFile||s.isSymbolicLink?!1:yield*Da(e.fs,n,o)});var Xw=new TextEncoder,gd=new TextDecoder,gg=new TextDecoder(\"utf-8\",{fatal:!0}),Jre=2**32;function k1(...e){let t=e.reduce((o,{length:s})=>o+s,0),n=new Uint8Array(t),r=0;for(let o of e)n.set(o,r),r+=o.length;return n}function Su(e){let t=new Uint8Array(e.length);for(let n=0;n127)throw new TypeError(\"non-ASCII string encountered in encode()\");t[n]=r}return t}var xg=(e,t=\"algorithm.name\")=>new TypeError(`CryptoKey does not support this operation, its ${t} must be ${e}`);function PY(e,t){if(t&&!e.usages.includes(t))throw new TypeError(`CryptoKey does not support this operation, its usages must include ${t}.`)}function R1(e,t){let{modulusLength:n}=t.algorithm;if(typeof n!=\"number\"||n<2048)throw new TypeError(`${e} requires key modulusLength to be 2048 bits or larger`)}function _1(e,t,n){let r=e.algorithm;if(r.name!==t.name)throw xg(t.name);if(t.hash&&r.hash?.name!==t.hash)throw xg(t.hash,\"algorithm.hash\");if(t.namedCurve&&r.namedCurve!==t.namedCurve)throw xg(t.namedCurve,\"algorithm.namedCurve\");if(t.length!==void 0&&r.length!==t.length)throw xg(t.length,\"algorithm.length\");PY(e,n)}function FY(e,t,...n){if(n.length>2){let r=n.pop();e+=`one of type ${n.join(\", \")}, or ${r}.`}else n.length===2?e+=`one of type ${n[0]} or ${n[1]}.`:e+=`of type ${n[0]}.`;return t==null?e+=` Received ${t}`:typeof t==\"function\"&&t.name?e+=` Received function ${t.name}`:typeof t==\"object\"&&t!=null&&t.constructor?.name&&(e+=` Received an instance of ${t.constructor.name}`),e}var xd=(e,t,...n)=>FY(`Key for the ${e} algorithm must be `,t,...n);var an=class extends Error{static code=\"ERR_JOSE_GENERIC\";code=\"ERR_JOSE_GENERIC\";constructor(t,n){super(t,n),this.name=this.constructor.name,Error.captureStackTrace?.(this,this.constructor)}},Is=class extends an{static code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},yd=class extends an{static code=\"ERR_JWT_EXPIRED\";code=\"ERR_JWT_EXPIRED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},yg=class extends an{static code=\"ERR_JOSE_ALG_NOT_ALLOWED\";code=\"ERR_JOSE_ALG_NOT_ALLOWED\"},co=class extends an{static code=\"ERR_JOSE_NOT_SUPPORTED\";code=\"ERR_JOSE_NOT_SUPPORTED\"};var Ln=class extends an{static code=\"ERR_JWS_INVALID\";code=\"ERR_JWS_INVALID\"},Eu=class extends an{static code=\"ERR_JWT_INVALID\";code=\"ERR_JWT_INVALID\"};var bd=class extends an{static code=\"ERR_JWKS_INVALID\";code=\"ERR_JWKS_INVALID\"},Iu=class extends an{static code=\"ERR_JWKS_NO_MATCHING_KEY\";code=\"ERR_JWKS_NO_MATCHING_KEY\";constructor(t=\"no applicable key found in the JSON Web Key Set\",n){super(t,n)}},bg=class extends an{[Symbol.asyncIterator]=async function*(){};static code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";constructor(t=\"multiple matching keys found in the JSON Web Key Set\",n){super(t,n)}},Sg=class extends an{static code=\"ERR_JWKS_TIMEOUT\";code=\"ERR_JWKS_TIMEOUT\";constructor(t=\"request timed out\",n){super(t,n)}},Eg=class extends an{static code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";constructor(t=\"signature verification failed\",n){super(t,n)}};var e0=e=>{if(e?.[Symbol.toStringTag]===\"CryptoKey\")return!0;try{return e instanceof CryptoKey}catch{return!1}},UY=e=>e?.[Symbol.toStringTag]===\"KeyObject\",M1=e=>e0(e)||UY(e);function P1(e){if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);let t=atob(e),n=new Uint8Array(t.length);for(let r=0;rtypeof o!=\"string\")||new Set(r).size!==r.length)throw new TypeError('\"key_ops\" (Key Operations) Parameter must be an array of unique strings');t.key_ops=r}return t}var o0=e=>e[Symbol.toStringTag],DY=(e,t,n)=>{let{alg:r}=e;if(t.use!==void 0){let o=n===\"sign\"||n===\"verify\"?\"sig\":\"enc\";if(t.use!==o)throw new TypeError(`Invalid key for this operation, its \"use\" must be \"${o}\" when present`)}if(t.alg!==void 0&&t.alg!==r)throw new TypeError(`Invalid key for this operation, its \"alg\" must be \"${r}\" when present`);if(Array.isArray(t.key_ops)){let o=n===\"encrypt\"||n===\"decrypt\"?e.ops?.[n===\"encrypt\"?0:1]:n;if(o&&!t.key_ops.includes(o))throw new TypeError(`Invalid key for this operation, its \"key_ops\" must include \"${o}\" when present`)}};function NY(e,t,n){let{alg:r,secret:o}=e,s=n===\"decrypt\"||n===\"sign\";if(o&&t instanceof Uint8Array)return[N1,t];if(fi(t)){let i=U1(t);if(typeof i.kty!=\"string\")throw new TypeError(o?xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(!(o?i.kty===\"oct\"&&typeof i.k==\"string\":i.kty!==\"oct\"&&(s?i.kty===\"AKP\"&&typeof i.priv==\"string\"||typeof i.d==\"string\":i.d===void 0&&i.priv===void 0)))throw new TypeError(o?'JSON Web Key for symmetric algorithms must have JWK \"kty\" (Key Type) equal to \"oct\" and the JWK \"k\" (Key Value) present':`JSON Web Key for this operation must be a ${s?\"private\":\"public\"} JWK`);return DY(e,i,n),[j1,t,i]}if(!M1(t))throw new TypeError(o?xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(o){if(t.type!==\"secret\")throw new TypeError(`${o0(t)} instances for symmetric algorithms must be of type \"secret\"`)}else{if(t.type===\"secret\")throw new TypeError(`${o0(t)} instances for asymmetric algorithms must not be of type \"secret\"`);let i=s?\"private\":\"public\";if((t.type===\"public\"||t.type===\"private\")&&t.type!==i){let a=n===\"sign\"?\"signing\":n===\"verify\"?\"verifying\":`${n.slice(0,-1)}tion`;throw new TypeError(`${o0(t)} instances for asymmetric algorithm ${a} must be of type \"${i}\"`)}}return e0(t)?[L1,t]:[$1,t]}var N1=0,L1=1,$1=2,j1=3,s0,LY={__proto__:null,prime256v1:\"P-256\",secp384r1:\"P-384\",secp521r1:\"P-521\"};function vg(e,t,n){s0||=new WeakMap;let r=s0.get(e);return n&&(r?r[t]=n:s0.set(e,{[t]:n})),n??r?.[t]}var D1=async(e,t,n)=>vg(e,n.alg)??vg(e,n.alg,await Tg(n,{...t,alg:n.alg})),$Y=(e,t)=>{let n=vg(e,t.alg);if(n)return n;let r=e.type===\"public\",o=t.usages[r?0:1],{asymmetricKeyType:s}=e,i=LY[e.asymmetricKeyDetails?.namedCurve],a=t.resolve?.({crv:i,asymmetricKeyType:s})??t.subtle;return vg(e,t.alg,e.toCryptoKey(a,r,o))};async function B1(e,t,n){let r=NY(e,t,n);switch(r[0]){case N1:case L1:return r[1];case j1:{let o=r[1],s=r[2];if(s.kty===\"oct\")return Sd(s.k);if(!Object.isFrozen(o)){let{key_ops:i}=o;Array.isArray(i)&&Object.freeze(i),Object.freeze(o)}return D1(o,s,e)}case $1:{let o=r[1];return o.type===\"secret\"?o.export():\"toCryptoKey\"in o&&typeof o.toCryptoKey==\"function\"?$Y(o,e):D1(o,o.export({format:\"jwk\"}),e)}}}function q1(e){let t={__proto__:null};for(let n in e)t[n]={...e[n],alg:n};return t}var z1={__proto__:null,b64:!0};function W1(e,t){if(t!==void 0&&(!Array.isArray(t)||t.some(n=>typeof n!=\"string\")))throw new TypeError(`\"${e}\" option must be an array of strings`);if(t)return new Set(t)}function H1(e,t,n,r,o){if(o.crit!==void 0&&r?.crit===void 0)throw new e('\"crit\" (Critical) Header Parameter MUST be integrity protected');if(!r||r.crit===void 0)return[];if(!Array.isArray(r.crit)||r.crit.length===0||r.crit.some(i=>typeof i!=\"string\"||i.length===0))throw new e('\"crit\" (Critical) Header Parameter MUST be an array of non-empty strings when present');let s=n===void 0?t:{__proto__:null,...n,...t};for(let i of r.crit){if(!(i in s))throw new co(`Extension Header Parameter \"${i}\" is not recognized`);if(!Object.hasOwn(o,i)||o[i]===void 0)throw new e(`Extension Header Parameter \"${i}\" is missing`);if(s[i]&&(!Object.hasOwn(r,i)||r[i]===void 0))throw new e(`Extension Header Parameter \"${i}\" MUST be integrity protected`)}return r.crit}function J1(e,t){if(t.includes(\"b64\")){let n=e.b64;if(typeof n!=\"boolean\")throw new Ln('The \"b64\" (base64url-encode payload) Header Parameter must be a boolean');return n}return!0}async function jY(e,t,n){return t instanceof Uint8Array?crypto.subtle.importKey(\"raw\",t,e.subtle,!1,[n]):(_1(t,e.subtle,n),e.minRsaBits&&R1(e.alg,t),t)}async function K1(e,t,n,r){let o=await jY(e,t,\"verify\");try{return await crypto.subtle.verify(e.signing,o,n,r)}catch{return!1}}var Ed=[[\"verify\"],[\"sign\"]];function i0(e){let t={name:\"HMAC\",hash:`SHA-${e}`};return{kty:[\"oct\"],secret:!0,subtle:t,signing:t,usages:Ed}}function wu(e,t){let r={name:t?\"RSA-PSS\":\"RSASSA-PKCS1-v1_5\",hash:`SHA-${e}`};return{kty:[\"RSA\"],subtle:r,signing:t?{...r,saltLength:t}:r,usages:Ed,minRsaBits:2048}}function a0(e,t){return{kty:[\"EC\"],crv:e,subtle:{name:\"ECDSA\",namedCurve:e},signing:{name:\"ECDSA\",hash:`SHA-${t}`},usages:Ed}}function G1(){let e={name:\"Ed25519\"};return{kty:[\"OKP\"],crv:\"Ed25519\",subtle:e,signing:e,usages:Ed}}function c0(e){let n={name:`ML-DSA-${e}`};return{kty:[\"AKP\"],subtle:n,signing:n,usages:Ed}}var u0=q1({HS256:i0(256),HS384:i0(384),HS512:i0(512),RS256:wu(256),RS384:wu(384),RS512:wu(512),PS256:wu(256,32),PS384:wu(384,48),PS512:wu(512,64),ES256:a0(\"P-256\",256),ES384:a0(\"P-384\",384),ES512:a0(\"P-521\",512),EdDSA:G1(),Ed25519:G1(),\"ML-DSA-44\":c0(44),\"ML-DSA-65\":c0(65),\"ML-DSA-87\":c0(87)});function V1(e){let t=typeof e==\"string\"?u0[e]:void 0;if(!t)throw new co(`alg ${e} is not supported either by JOSE or your javascript runtime`);return t}function Z1(e){return[e&&W1(\"algorithms\",e.algorithms),e?.crit]}function BY(e,t=e===void 0?{}:wg(e,Ln,\"JWS Protected Header is invalid\")){return t}function qY(e,t,n){let r=J1(e,H1(Ln,z1,n[1],e,t)),o=t.alg;if(typeof o!=\"string\"||!o)throw new Ln('JWS \"alg\" (Algorithm) Header Parameter missing or invalid');if(n[0]&&!n[0].has(o))throw new yg('\"alg\" (Algorithm) Header Parameter value not allowed');return[r,o]}function zY(e){try{return Su(e)}catch{throw new Ln(\"JWS Compact Serialization payload must use only ASCII characters\")}}async function WY(e,t,n,r,o,s,i){let a=!1;typeof n==\"function\"&&(n=await n(o,e),a=!0);let c=typeof i==\"string\",u=V1(s),f=k1(r!==void 0?Su(r):new Uint8Array,Su(\".\"),c?t[2]??=F1(i,\"payload\",Ln):i),d=n0(e.signature,\"signature\",Ln),p=await B1(u,n,\"verify\");if(!await K1(u,p,d,f))throw new Eg;return[c?n0(i,\"payload\",Ln):i,o,c,p,a]}async function Y1(e,t,n){if(e instanceof Uint8Array&&(e=gd.decode(e)),typeof e!=\"string\")throw new Ln(\"Compact JWS must be a string or Uint8Array\");let{0:r,1:o,2:s,length:i}=e.split(\".\");if(i!==3)throw new Ln(\"Invalid Compact JWS\");let a={payload:o,protected:r,signature:s},c=BY(r),[u,f]=qY(c,c,t),d=u?o:zY(o);return WY(a,t,n,r,c,f,d)}var HY=e=>Math.floor(e.getTime()/1e3),JY={s:1,m:60,h:3600,d:86400,w:604800,y:31557600},KY=/^(\\+|\\-)? ?(\\d+|\\d+\\.\\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i,Tu=\"check_failed\";function f0(){throw new TypeError(\"Invalid time period format\")}function Q1(e){typeof e!=\"string\"&&f0();let t=KY.exec(e);(!t||t[4]&&t[1])&&f0();let n=parseFloat(t[2]),r=Math.round(n*JY[t[3][0].toLowerCase()]);return Number.isFinite(r)||f0(),t[1]===\"-\"||t[4]===\"ago\"?-r:r}function l0(e,t){if(!Number.isFinite(t))throw new TypeError(`Invalid ${e} input`);return t}var X1=e=>{let t=e.toLowerCase();return e.includes(\"/\")?t:`application/${t}`},GY=(e,t)=>typeof e==\"string\"?t.includes(e):Array.isArray(e)?t.some(n=>e.includes(n)):!1;function d0(e,t,n=!1){let r=e[t];if(!(r===void 0&&!n)){if(typeof r!=\"number\")throw new Is(`\"${t}\" claim must be a number`,e,t,\"invalid\");return r}}function p0(e,t){throw new Is(`unexpected \"${t}\" claim value`,e,t,Tu)}function e2(e,t,n={}){let r;try{r=JSON.parse(gg.decode(t))}catch{}if(!fi(r))throw new Eu(\"JWT Claims Set must be a top-level JSON object\");let{typ:o}=n;if(o!==void 0&&(typeof e.typ!=\"string\"||X1(e.typ)!==X1(o)))throw new Is('unexpected \"typ\" JWT header value',r,\"typ\",Tu);let{requiredClaims:s=[],issuer:i,subject:a,audience:c,maxTokenAge:u}=n,f=[...s];u!==void 0&&f.push(\"iat\"),c!==void 0&&f.push(\"aud\"),a!==void 0&&f.push(\"sub\"),i!==void 0&&f.push(\"iss\");for(let E of new Set(f.reverse()))if(!Object.hasOwn(r,E))throw new Is(`missing required \"${E}\" claim`,r,E,\"missing\");i!==void 0&&!(Array.isArray(i)?i:[i]).includes(r.iss)&&p0(r,\"iss\"),a!==void 0&&r.sub!==a&&p0(r,\"sub\"),c!==void 0&&!GY(r.aud,typeof c==\"string\"?[c]:c)&&p0(r,\"aud\");let{clockTolerance:d}=n,p=0;if(typeof d==\"string\")p=Q1(d);else if(d!==void 0){if(typeof d!=\"number\")throw new TypeError(\"Invalid clockTolerance option type\");p=d}l0(\"clockTolerance option\",p);let{currentDate:m}=n,g=l0(\"currentDate option\",HY(m===void 0?new Date:m)),b=d0(r,\"iat\",u!==void 0),I=d0(r,\"nbf\");if(I!==void 0&&I>g+p)throw new Is('\"nbf\" claim timestamp check failed',r,\"nbf\",Tu);let w=d0(r,\"exp\");if(w!==void 0&&w<=g-p)throw new yd('\"exp\" claim timestamp check failed',r,\"exp\",Tu);if(u!==void 0){let E=g-b,h=l0(\"maxTokenAge option\",typeof u==\"number\"?u:Q1(u));if(E-p>h)throw new yd('\"iat\" claim timestamp check failed (too far in the past)',r,\"iat\",Tu);if(E<-p)throw new Is('\"iat\" claim timestamp check failed (it should be in the past)',r,\"iat\",Tu)}return r}async function Ag(e,t,n){let r=await Y1(e,Z1(n),t);if(!r[2])throw new Eu(\"JWTs MUST NOT use unencoded payload\");let s={payload:e2(r[1],r[0],n),protectedHeader:r[1]};return typeof t==\"function\"?{...s,key:r[3]}:s}function VY(e,t,n,r){let{kty:o,key_ops:s,ext:i,kid:a,alg:c,use:u,crv:f}=r0(e),d=Array.isArray(s)?[...s]:s;return(i===void 0||typeof i==\"boolean\")&&(d===void 0||Array.isArray(d)&&d.every((p,m)=>typeof p==\"string\"&&d.indexOf(p)===m)&&d.includes(\"verify\"))&&t.kty.includes(o)&&(r===void 0||typeof r==\"string\"&&r===a)&&(c===void 0?o!==\"AKP\":n===c)&&(u===void 0||u===\"sig\")&&(!t.crv||f===t.crv)}async function t2(e,t,n){let r=e.get(t)||e.set(t,{}).get(t),{alg:o}=n;if(r[o]===void 0){let s=await Tg(n,{...t,alg:o,ext:!0});if(s.type!==\"public\")throw new bd(\"JSON Web Key Set members must be public keys\");r[o]=s}return r[o]}function Na(e){let t;try{t=structuredClone(e)}catch{}if(!Ig(t))throw new bd(\"JSON Web Key Set malformed\");let n=new WeakMap;return Object.defineProperty(async(o,s)=>{let{alg:i,kid:a}={...o,...s?.header},c=typeof i==\"string\"?u0[i]:void 0;if(!c||c.secret)throw new co('Unsupported \"alg\" value for a JSON Web Key Set');let u=t.keys.filter(p=>VY(p,c,i,a)),{0:f,length:d}=u;if(!d)throw new Iu;if(d!==1){let p=new bg;throw p[Symbol.asyncIterator]=async function*(){for(let m of u)try{yield await t2(n,m,c)}catch{}},p}return t2(n,f,c)},\"jwks\",{value:()=>structuredClone(t)})}function ZY(){return typeof WebSocketPair<\"u\"||typeof navigator<\"u\"&&navigator.userAgent===\"Cloudflare-Workers\"||typeof EdgeRuntime<\"u\"&&EdgeRuntime===\"vercel\"}var m0;(typeof navigator>\"u\"||!navigator.userAgent?.startsWith?.(\"Mozilla/5.0 \"))&&(m0=\"jose/v6.2.10\");var r2=Symbol();async function YY(e,t,n,r=fetch){let o=await r(e,{method:\"GET\",signal:n,redirect:\"manual\",headers:t}).catch(s=>{throw s.name===\"TimeoutError\"?new Sg:s});if(o.status!==200)throw new an(\"Expected 200 OK from the JSON Web Key Set HTTP response\");try{return await o.json()}catch{throw new an(\"Failed to parse the JSON Web Key Set HTTP response as JSON\")}}var o2=Symbol();function Id(e,t){return Number.isFinite(e)&&Date.now(){if(p&&ZY()&&(p=void 0),!p){let E=++m,h=p=YY(n,c,AbortSignal.timeout(s),u).then(A=>{let y=Na(A);if(E<=g)return;b=y;let S=Date.now();f&&(f.uat=S,f.jwks=A),d=S,g=E}).finally(()=>{p===h&&(p=void 0)})}await p};return Object.defineProperties(async(E,h)=>{(!b||!Id(d,a))&&await I();try{return await b(E,h)}catch(A){if(A instanceof Iu&&!Id(d,i))return await I(),b(E,h);throw A}},{coolingDown:{get:()=>Id(d,i),enumerable:!0},fresh:{get:()=>Id(d,a),enumerable:!0},reload:{value:I,enumerable:!0},reloading:{get:()=>!!p,enumerable:!0},jwks:{value:()=>b?.jwks(),enumerable:!0}})}function g0(e){let t;if(typeof e==\"string\"){let r=e.split(\".\");(r.length===3||r.length===5)&&([t]=r)}else if(typeof e==\"object\"&&e)if(\"protected\"in e)t=e.protected;else throw new TypeError(\"Token does not contain a Protected Header\");let n=\"Invalid Token or Protected Header formatting\";if(typeof t!=\"string\"||!t)throw new TypeError(n);return wg(t,TypeError,n)}var XY=new Set([\"HOME\",\"HOSTNAME\",\"PATH\",\"PWD\"]),Rr=D.runSync(D.gen(function*(){let e=t=>t.pipe(An.option);return{hostPort:yield*e(An.string(\"SUPABASE_INTERNAL_HOST_PORT\")),functionsRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_ROOT\")),filesRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_FILES_ROOT\")),jwtSecret:yield*e(An.string(\"SUPABASE_INTERNAL_JWT_SECRET\")),supabaseUrl:yield*e(An.string(\"SUPABASE_URL\")),wallclock:yield*e(An.string(\"SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC\")),publishableKey:yield*e(An.string(\"SUPABASE_INTERNAL_PUBLISHABLE_KEY\")),secretKey:yield*e(An.string(\"SUPABASE_INTERNAL_SECRET_KEY\")),functionsConfig:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_CONFIG\")),debug:yield*e(An.string(\"SUPABASE_INTERNAL_DEBUG\")),jwks:yield*e(An.string(\"SUPABASE_JWKS\"))}}).pipe(D.provideService(gu.ConfigProvider,gu.fromEnvRecord(Deno.env.toObject(),{preserveEmptyStrings:!0})))),Au=(e,t=\"\")=>ye.getOrElse(e,()=>t),s2=Au(Rr.hostPort,\"8081\"),l2=Au(Rr.functionsRoot),eQ=Au(Rr.jwtSecret),tQ=Au(Rr.supabaseUrl,\"http://127.0.0.1:54321\"),nQ=new URL(\"/auth/v1/.well-known/jwks.json\",tQ),i2=Number.parseInt(Au(Rr.wallclock,\"400\"),10),a2=ye.getOrUndefined(Rr.publishableKey),c2=ye.getOrUndefined(Rr.secretKey),ws={BootError:Nn.ServiceUnavailable,InvalidWorkerResponse:Nn.InternalServerError,WorkerLimit:546},rQ={[ws.BootError]:\"BOOT_ERROR\",[ws.InvalidWorkerResponse]:\"WORKER_ERROR\",[ws.WorkerLimit]:\"WORKER_LIMIT\"},oQ={[ws.BootError]:\"Worker failed to boot (please check logs)\",[ws.InvalidWorkerResponse]:\"Function exited due to an error (please check logs)\",[ws.WorkerLimit]:\"Worker failed to respond due to a resource limit (please check logs)\"},sQ=new Map([[Deno.errors.InvalidWorkerCreation,ws.BootError],[Deno.errors.InvalidWorkerResponse,ws.InvalidWorkerResponse],[Deno.errors.WorkerRequestCancelled,ws.WorkerLimit]]),iQ=e=>{let t=Deno.errors.WorkerAlreadyRetired;return t!==void 0&&e instanceof t},aQ=(s=>(s.MissingAuthHeader=\"UNAUTHORIZED_NO_AUTH_HEADER\",s.InvalidLegacyJWT=\"UNAUTHORIZED_JWT\",s.InvalidAsymmetricJWT=\"UNAUTHORIZED_ASYMMETRIC_JWT\",s.InvalidTokenFormat=\"UNAUTHORIZED_INVALID_JWT_FORMAT\",s.UnsupportedTokenAlgorithm=\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",s))(aQ||{}),cQ=le.Struct({enabled:le.optionalKey(le.Boolean),verifyJWT:le.optionalKey(le.Boolean),verify_jwt:le.optionalKey(le.Boolean),entrypointPath:le.optionalKey(le.String),entrypoint:le.optionalKey(le.String),importMapPath:le.optionalKey(le.String),import_map:le.optionalKey(le.String),importMapRoot:le.optionalKey(le.String),import_map_root:le.optionalKey(le.String),staticFiles:le.optionalKey(le.Array(le.String)),static_files:le.optionalKey(le.Array(le.String)),env:le.optionalKey(le.Record(le.String,le.String))}),uQ=le.Record(le.String,cQ),fQ=le.declare(e=>typeof e==\"object\"&&e!==null&&\"keys\"in e&&Array.isArray(e.keys)&&e.keys.every(t=>typeof t==\"object\"&&t!==null)),lQ=()=>ye.match(Rr.functionsConfig,{onNone:()=>({}),onSome:e=>D.runSync(le.decodeEffect(le.fromJsonString(uQ))(e).pipe(D.orElseSucceed(()=>({}))))}),x0=lQ();if(ye.getOrUndefined(Rr.debug)===\"true\"){let e=Object.fromEntries(Object.entries(x0).map(([t,n])=>[t,Object.fromEntries(Object.entries(n).filter(([r])=>r!==\"env\"))]));D.runSync(Ua.log(\"Functions config:\",JSON.stringify(e,null,2)))}var La=(e,t,n={})=>{let r={...n},o=null;return e!=null&&(typeof e==\"object\"?(r[\"Content-Type\"]=\"application/json\",o=JSON.stringify(e)):(r[\"Content-Type\"]=\"text/plain\",o=typeof e==\"string\"?e:JSON.stringify(e)??null)),new Response(o,{status:t,headers:r})},u2=({code:e,message:t=\"Invalid JWT\"})=>La({code:e,message:t,msg:t},Nn.Unauthorized,{\"sb-error-code\":e,\"Access-Control-Expose-Headers\":\"sb-error-code\"}),dQ=e=>{for(let[t,n]of sQ.entries())if(t!==void 0&&e instanceof t)return La({code:rQ[n],message:oQ[n]},n);return La({code:Yw[Nn.InternalServerError],message:\"Request failed due to an internal server error\"},Nn.InternalServerError)};function pQ(e){let t=e.split(\" \");return t.length===2&&t[0]===\"Bearer\"?t[1]:null}var mQ=e=>{let t=e.headers.get(\"authorization\"),n=e.headers.get(\"sb-api-key\")?.replace(\"Bearer\",\"\").trim();if(!t&&!n)return{code:\"UNAUTHORIZED_NO_AUTH_HEADER\",message:\"Missing authorization header\"};let r=pQ(t??\"\"),o=!r||r.startsWith(\"sb_\")?n:r;return o||{code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"}},vu=class extends bo.TaggedError(\"BootstrapOperationError\"){},hQ=D.runSync(ye.match(Rr.jwks,{onNone:()=>D.succeed(ye.some(Na({keys:[]}))),onSome:e=>D.gen(function*(){let t=yield*le.decodeEffect(le.fromJsonString(fQ))(e);return yield*D.try({try:()=>Na(t),catch:n=>new vu({cause:n})})}).pipe(D.option)})),gQ=ye.getOrElse(hQ,()=>h0(nQ)),li=e=>D.tryPromise({try:e,catch:t=>new vu({cause:t})}),xQ=e=>D.gen(function*(){return yield*li(()=>Ag(e,gQ)),ye.none()}).pipe(D.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_ASYMMETRIC_JWT\"})));function yQ(e,t){return D.gen(function*(){let n=yield*D.try({try:()=>g0(t).alg,catch:r=>new vu({cause:r})});return n?n===\"HS256\"?yield*li(()=>Ag(t,new TextEncoder().encode(e))).pipe(D.as(ye.none()),D.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_JWT\"}))):n===\"ES256\"||n===\"RS256\"?yield*xQ(t):ye.some({code:\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",message:`Unsupported JWT algorithm ${n}`}):ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})}).pipe(D.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})))}var d2={lstat:e=>li(()=>Deno.lstat(e)).pipe(D.mapError(t=>new bu({cause:t})),D.map(t=>({isDirectory:t.isDirectory,isFile:t.isFile,isSymbolicLink:t.isSymlink}))),realPath:e=>li(()=>Deno.realPath(e)).pipe(D.mapError(t=>new bu({cause:t}))),readDirectory:e=>Do.suspend(()=>Do.fromAsyncIterable(Deno.readDir(e),t=>new vu({cause:t})).pipe(Do.map(t=>t.name))).pipe(Do.runCollect,D.mapError(t=>new bu({cause:t})))},bQ=e=>A1({root:l2,filesRoot:ye.getOrUndefined(Rr.filesRoot),slug:e,overrides:x0,fs:d2}),f2=new Set,SQ=(e,t)=>t.importMapDiscoveredByRuntime||!v1(t.importMapPath)||f2.has(e)?D.void:D.sync(()=>f2.add(e)).pipe(D.andThen(Ua.warn(`[functions] ${e}: ${t.importMapPath} is not the nearest Deno config of ${t.entrypointPath}, so Edge Runtime loads it as a plain import map without comments or jsr:/npm: subpath imports. Move it next to the entrypoint or into a parent directory without a closer deno.json(c).`))),EQ=C1(()=>Deno.makeTempDirSync({prefix:\"supabase-worker-\"})),IQ=e=>e.importMapPath?D.succeed(!1):O1({root:Au(Rr.filesRoot,l2),config:e,fs:d2}),p2=e=>Do.fromEffectRepeat(li(()=>e.read()).pipe(D.flatMap(t=>t.done?Wt.done():D.succeed(t.value)))),wQ=e=>e===void 0?D.void:Do.runDrain(p2(e)).pipe(D.ignore);function TQ(e,t){let n=new URL(e.url),r=e.headers.get(\"x-forwarded-host\");r&&(n.hostname=r);let o=new Request(n.href,{method:e.method,headers:e.headers,body:t===void 0?null:Do.toReadableStream(p2(t)),signal:e.signal,duplex:\"half\"});return o.headers.delete(\"sb-api-key\"),EdgeRuntime.applySupabaseTag(e,o),o}Deno.serve({handler:e=>D.runPromiseExit(D.gen(function*(){let t=yield*D.acquireRelease(D.sync(()=>e.body?.getReader()),u=>D.interruptible(wQ(u))),{pathname:n}=new URL(e.url);if(n===\"/_internal/health\")return La({message:\"ok\"},Nn.OK);if(n===\"/_internal/metric\")return Response.json(yield*li(()=>EdgeRuntime.getRuntimeMetrics()));let r=n.split(\"/\")[1];if(!r)return La(\"Function not found\",Nn.NotFound);let o=yield*bQ(r);if(!o)return La(\"Function not found\",Nn.NotFound);if(yield*SQ(r,o),e.method!==\"OPTIONS\"&&o.verifyJWT){let u=mQ(e);if(typeof u!=\"string\")return u2(u);let f=yield*yQ(eQ,u);if(ye.isSome(f))return u2(f.value)}let s={...Deno.env.toObject(),...Object.fromEntries(Object.entries(o.env??{}).filter(([u])=>!u.startsWith(\"SUPABASE_\"))),SUPABASE_FUNCTION_SLUG:r};a2&&(s.SUPABASE_PUBLISHABLE_KEYS=yield*le.encodeEffect(le.fromJsonString(le.Unknown))({default:a2})),c2&&(s.SUPABASE_SECRET_KEYS=yield*le.encodeEffect(le.fromJsonString(le.Unknown))({default:c2}));let i=Object.entries(s).filter(([u])=>!XY.has(u)&&!u.startsWith(\"SUPABASE_INTERNAL_\")),a=!(yield*IQ(o));return yield*D.gen(function*(){let u=yield*li(()=>EdgeRuntime.userWorkers.create({servicePath:EQ(r,o),memoryLimitMb:256,workerTimeoutMs:Number.isFinite(i2)?i2*1e3:4e5,noModuleCache:!0,noNpm:a,envVars:i,forceCreate:!0,customModuleRoot:\"\",cpuTimeSoftLimitMs:1e3,cpuTimeHardLimitMs:2e3,decoratorType:\"tc39\",maybeEntrypoint:I1(o.entrypointPath).href,context:{useReadSyncFileAPI:!0,...o.importMapPath===\"\"||o.importMapDiscoveredByRuntime?{}:{importMapPath:o.importMapPath}},staticPatterns:o.staticFiles}));return yield*li(()=>u.fetch(TQ(e,t)))}).pipe(D.retry({times:1,while:({cause:u})=>e.body===null&&iQ(u)}),D.catchTag(\"BootstrapOperationError\",({cause:u})=>Ua.error(\"[functions] worker error\",u).pipe(D.andThen(D.succeed(dQ(u))))))}).pipe(D.scoped),{signal:e.signal}).then(t=>{if(Xt.isSuccess(t))return t.value;if(e.signal.aborted&&Wt.hasInterruptsOnly(t.cause))return new Response(null,{status:499});throw Wt.squash(t.cause)}),onListen:()=>{let t=Object.keys(x0).slice(0,5).map(n=>` - http://127.0.0.1:${s2}/functions/v1/${n}`);D.runSync(Ua.log(`Serving functions on http://127.0.0.1:${s2}/functions/v1/${t.length?`\n${t.join(`\n`)}`:\"\"}\nUsing ${Deno.version.deno}`))},onError:()=>La({code:Yw[500],message:\"Request failed due to an internal server error\"},500)});export{aQ as RequestErrors,pQ as extractBearerToken,TQ as prepareUserRequest};\n"; diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index 16f30cbc18..41f4e47798 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -36,6 +36,7 @@ describe("Edge Runtime worker service paths", () => { const alpha = { entrypointPath: "/functions/shared/alpha.ts", importMapPath: "", + importMapDiscoveredByRuntime: false, staticFiles: [], verifyJWT: true, }; @@ -265,6 +266,120 @@ describe("Edge Runtime request-time function resolver", () => { }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("marks the Deno config Edge Runtime discovers from the entrypoint", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-deno-config-", + }); + const hello = path.join(root, "hello"); + const shared = path.join(root, "shared"); + yield* fs.makeDirectory(hello, { recursive: true }); + yield* fs.makeDirectory(shared, { recursive: true }); + yield* fs.writeFileString(path.join(hello, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(hello, "deno.json"), "{}"); + yield* fs.writeFileString(path.join(shared, "index.ts"), "export default 2"); + yield* fs.writeFileString(path.join(root, "deno.json"), "{}"); + const canonicalRoot = yield* fs.realPath(root); + + const template = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { import_map: path.join(canonicalRoot, "hello", "deno.json") } }, + fs: nodeFileSystem, + }); + const discovered = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: {}, + fs: nodeFileSystem, + }); + const ancestor = yield* resolveFunctionConfig({ + root, + slug: "shared", + overrides: { $default: { import_map_root: "deno.json" } }, + fs: nodeFileSystem, + }); + + expect(template).toMatchObject({ + importMapPath: path.join(canonicalRoot, "hello", "deno.json"), + importMapDiscoveredByRuntime: true, + }); + expect(discovered).toMatchObject({ + importMapPath: path.join(canonicalRoot, "hello", "deno.json"), + importMapDiscoveredByRuntime: true, + }); + expect(ancestor).toMatchObject({ + importMapPath: path.join(canonicalRoot, "deno.json"), + importMapDiscoveredByRuntime: true, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("walks from the normalized entrypoint when it contains parent segments", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-parent-segments-", + }); + const canonicalRoot = yield* fs.realPath(root); + const hello = path.join(canonicalRoot, "hello"); + yield* fs.makeDirectory(path.join(hello, "nested"), { recursive: true }); + yield* fs.writeFileString(path.join(hello, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(hello, "nested", "deno.json"), "{}"); + yield* fs.writeFileString(path.join(canonicalRoot, "deno.json"), "{}"); + + const config = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { + $default: { import_map_root: "deno.json" }, + hello: { entrypointPath: `${hello}/nested/../index.ts` }, + }, + fs: nodeFileSystem, + }); + + expect(config).toMatchObject({ + importMapPath: path.join(canonicalRoot, "deno.json"), + importMapDiscoveredByRuntime: true, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("keeps import maps Edge Runtime would not discover as explicit import maps", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-plain-import-map-", + }); + const hello = path.join(root, "hello"); + yield* fs.makeDirectory(hello, { recursive: true }); + yield* fs.writeFileString(path.join(hello, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(hello, "deno.json"), "{}"); + yield* fs.writeFileString(path.join(hello, "import_map.json"), "{}"); + yield* fs.writeFileString(path.join(root, "deno.json"), "{}"); + + const plain = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { import_map: "import_map.json" } }, + fs: nodeFileSystem, + }); + const shadowed = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { $default: { import_map_root: "deno.json" } }, + fs: nodeFileSystem, + }); + + expect(plain?.importMapDiscoveredByRuntime).toBe(false); + expect(shadowed?.importMapDiscoveredByRuntime).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.live("accepts a symlinked functions root while enforcing canonical descendants", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -374,6 +489,7 @@ describe("Edge Runtime request-time function resolver", () => { const config = { entrypointPath: path.join(functionRoot, "index.ts"), importMapPath: "", + importMapDiscoveredByRuntime: false, staticFiles: [], verifyJWT: true, }; diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index 9c9f53e8bd..6715be1ead 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -23,6 +23,12 @@ export type FunctionOverrides = Readonly>; export interface FunctionConfig { readonly entrypointPath: string; readonly importMapPath: string; + /** + * Whether Edge Runtime already loads `importMapPath` as the Deno config it discovers from the + * entrypoint. Passing it as a plain import map would drop config semantics such as `jsr:` subpath + * expansion. + */ + readonly importMapDiscoveredByRuntime: boolean; readonly staticFiles: ReadonlyArray; readonly verifyJWT: boolean; readonly env?: Readonly>; @@ -93,6 +99,40 @@ const rejectSymlinkDescendants = ( const relativePath = (base: string, value: string): string => value.length === 0 ? "" : value.startsWith("/") ? value : join(base, value); +const denoConfigNames = ["deno.json", "deno.jsonc"]; + +/** Whether a path names a Deno config file rather than a plain import map. */ +export const isDenoConfigPath = (path: string): boolean => + denoConfigNames.includes(path.slice(path.lastIndexOf("/") + 1)); + +/** Mirrors Deno's nearest-config lookup from the entrypoint directory, bounded by `filesRoot`. */ +const nearestDenoConfig = ( + fs: FunctionFileSystem, + filesRoot: string, + entrypointPath: string, +): Effect.Effect => + Effect.gen(function* () { + // Edge Runtime receives the entrypoint as a file URL, which resolves `..` segments. + let directory = dirname(join(entrypointPath)); + while (contained(filesRoot, directory)) { + for (const name of denoConfigNames) { + const candidate = join(directory, name); + const info = yield* optionalInfo(fs, candidate); + if (info !== undefined) return info.isFile && !info.isSymbolicLink ? candidate : undefined; + } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return undefined; + }); + +const samePath = (fs: FunctionFileSystem, left: string, right: string): Effect.Effect => + Effect.all([fs.realPath(left), fs.realPath(right)], { concurrency: 2 }).pipe( + Effect.map(([canonicalLeft, canonicalRight]) => canonicalLeft === canonicalRight), + Effect.orElseSucceed(() => false), + ); + /** Resolves one request's persisted override/default against the live functions tree. */ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig")( function* (options: { @@ -160,7 +200,7 @@ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig" const info = yield* optionalInfo(fs, importMapPath); if (info === undefined || !info.isFile || info.isSymbolicLink) return undefined; } else { - for (const candidate of ["deno.json", "deno.jsonc"]) { + for (const candidate of denoConfigNames) { const path = join(functionDirectory, candidate); const info = yield* optionalInfo(fs, path); if (info !== undefined) { @@ -171,6 +211,13 @@ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig" } } } + const discoveredDenoConfig = + importMapPath.length === 0 + ? undefined + : yield* nearestDenoConfig(fs, filesRoot, entrypointPath); + const importMapDiscoveredByRuntime = + discoveredDenoConfig !== undefined && + (yield* samePath(fs, discoveredDenoConfig, importMapPath)); const staticFiles = (override.staticFiles ?? override.static_files ?? []).map((pattern) => relativePath(functionDirectory, pattern), @@ -197,6 +244,7 @@ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig" return { entrypointPath, importMapPath, + importMapDiscoveredByRuntime, staticFiles, verifyJWT: override.verifyJWT ?? override.verify_jwt ?? true, env: override.env, diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index fb8fdd06cc..20766f7478 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -60,6 +60,7 @@ declare const EdgeRuntime: EdgeRuntimeApi; import { STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; import { createWorkerServicePathResolver, + isDenoConfigPath, packageJsonContainedFor, resolveFunctionConfig, type FunctionConfig, @@ -350,6 +351,20 @@ const functionConfig = (slug: string): Effect.Effect overrides: configured, fs: denoFileSystem, }); +const warnedPlainDenoConfigs = new Set(); +// Edge Runtime has no user-worker option to load a Deno config from an arbitrary path. +const warnPlainDenoConfig = (slug: string, config: FunctionConfig): Effect.Effect => + config.importMapDiscoveredByRuntime || + !isDenoConfigPath(config.importMapPath) || + warnedPlainDenoConfigs.has(slug) + ? Effect.void + : Effect.sync(() => warnedPlainDenoConfigs.add(slug)).pipe( + Effect.andThen( + Console.warn( + `[functions] ${slug}: ${config.importMapPath} is not the nearest Deno config of ${config.entrypointPath}, so Edge Runtime loads it as a plain import map without comments or jsr:/npm: subpath imports. Move it next to the entrypoint or into a parent directory without a closer deno.json(c).`, + ), + ), + ); const workerServicePath = createWorkerServicePathResolver(() => Deno.makeTempDirSync({ prefix: "supabase-worker-" }), ); @@ -411,6 +426,7 @@ Deno.serve({ if (!functionName) return getResponse("Function not found", STATUS_CODE.NotFound); const config = yield* functionConfig(functionName); if (!config) return getResponse("Function not found", STATUS_CODE.NotFound); + yield* warnPlainDenoConfig(functionName, config); if (request.method !== "OPTIONS" && config.verifyJWT) { const token = getAuthToken(request); if (typeof token !== "string") return getAuthErrorResponse(token); @@ -455,7 +471,9 @@ Deno.serve({ maybeEntrypoint: toFileUrl(config.entrypointPath).href, context: { useReadSyncFileAPI: true, - ...(config.importMapPath === "" ? {} : { importMapPath: config.importMapPath }), + ...(config.importMapPath === "" || config.importMapDiscoveredByRuntime + ? {} + : { importMapPath: config.importMapPath }), }, staticPatterns: config.staticFiles, }), diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index f6f6d05ffc..053c069f2d 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, FileSystem, Layer, Ref, Schema, Stream } from "effect"; +import { Cause, Deferred, Effect, FileSystem, Layer, Ref, Schema, Stream } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { makeService } from "../Service.ts"; @@ -200,6 +200,160 @@ describe("service catalog", () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 120_000 }, ); + + it.live( + "loads a function's configured deno.jsonc as its Deno config", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const temporaryRoot = `${process.cwd()}/tmp`; + yield* fs.makeDirectory(temporaryRoot, { recursive: true }); + const root = yield* fs.makeTempDirectoryScoped({ + directory: temporaryRoot, + prefix: "functions-deno-config-", + }); + const functionsRoot = `${root}/supabase/functions`; + yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); + yield* fs.writeFileString( + `${functionsRoot}/hello/deno.jsonc`, + '// Deno config files allow comments; plain import maps do not.\n{"imports":{"message":"./message.ts"}}', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/message.ts`, + 'export const message = "config";', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/index.ts`, + 'import { message } from "message"; Deno.serve(() => new Response(message));', + ); + const recipe = yield* makeServiceRecipe( + { + service: "functions", + config: { + functionsRoot, + verifyJwt: false, + functions: { hello: { import_map: `${functionsRoot}/hello/deno.jsonc` } }, + }, + }, + { + ...options(root), + stackId: "e".repeat(64), + instanceId: "deno-config", + cacheRoot: "/tmp/supabase-stack-artifacts", + }, + ); + const logs = yield* Ref.make(""); + yield* recipe.logs.pipe( + Stream.runForEach(({ bytes }) => + Ref.update(logs, (text) => text + new TextDecoder().decode(bytes)), + ), + Effect.forkScoped({ startImmediately: true }), + ); + const instance = yield* makeService(recipe.definition, { + id: "deno-config", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready.pipe( + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + const endpoint = yield* recipe.endpoint("http"); + + const response = yield* client.get(`http://${endpoint.host}:${endpoint.port}/hello`); + + expect(response.status, yield* Ref.get(logs)).toBe(200); + expect(yield* response.text).toBe("config"); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + + it.live( + "warns when a configured Deno config is not the one Edge Runtime discovers", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const temporaryRoot = `${process.cwd()}/tmp`; + yield* fs.makeDirectory(temporaryRoot, { recursive: true }); + const root = yield* fs.makeTempDirectoryScoped({ + directory: temporaryRoot, + prefix: "functions-plain-deno-config-", + }); + const functionsRoot = `${root}/supabase/functions`; + yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); + yield* fs.makeDirectory(`${functionsRoot}/_shared`, { recursive: true }); + yield* fs.writeFileString( + `${functionsRoot}/_shared/deno.jsonc`, + '{"imports":{"message":"./message.ts"}}', + ); + yield* fs.writeFileString( + `${functionsRoot}/_shared/message.ts`, + 'export const message = "shared";', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/index.ts`, + 'import { message } from "message"; Deno.serve(() => new Response(message));', + ); + const recipe = yield* makeServiceRecipe( + { + service: "functions", + config: { + functionsRoot, + verifyJwt: false, + functions: { hello: { import_map: `${functionsRoot}/_shared/deno.jsonc` } }, + }, + }, + { + ...options(root), + stackId: "f".repeat(64), + instanceId: "plain-deno-config", + cacheRoot: "/tmp/supabase-stack-artifacts", + }, + ); + const logs = yield* Ref.make(""); + const warned = yield* Deferred.make(); + yield* recipe.logs.pipe( + Stream.runForEach(({ bytes }) => + Ref.updateAndGet(logs, (text) => text + new TextDecoder().decode(bytes)).pipe( + Effect.flatMap((text) => + text.includes("is not the nearest Deno config") + ? Deferred.succeed(warned, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ); + const instance = yield* makeService(recipe.definition, { + id: "plain-deno-config", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready.pipe( + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + const endpoint = yield* recipe.endpoint("http"); + + const response = yield* client.get(`http://${endpoint.host}:${endpoint.port}/hello`); + yield* Deferred.await(warned).pipe( + Effect.timeout("30 seconds"), + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + + expect(response.status).toBe(200); + expect(yield* Ref.get(logs)).toContain( + `hello: ${yield* fs.realPath(functionsRoot)}/_shared/deno.jsonc is not the nearest Deno config`, + ); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); }); for (const runtime of ["native", "docker"] as const) { From b8fae815ea95b5e298a02c2a97335f17a3652b03 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 12:32:59 +0000 Subject: [PATCH 52/71] fix(cli): tidy stack start and non-TTY output friction (#6889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR Several rough edges show up when the experimental `stack` backend runs in scripts, worktrees, or on Windows: spinner escape codes in non-TTY logs, a self-answered bucket prompt, a wrong branch name after `db reset`, a leftover stack after an unsupported native start, and top-level help that describes `stack` subcommands. This PR fixes each one. ## Before ```mermaid flowchart LR A["stack start --runtime native
on win32"] --> B["Stack created"] --> C["Artifact check fails"] --> D["Stopped stack left behind"] ``` ## After ```mermaid flowchart LR A["stack start --runtime native
on win32"] --> B["Runtime preflight fails"] --> C["No stack created"] ``` ## Why - Non-TTY output (CI, agents, `--agent no`) was flooded with spinner escape codes. - `stack start` printed `Bucket X already exists. Do you want to overwrite its properties? [Y/n]` and answered it itself. - `db reset` in a detached-HEAD worktree printed `Finished supabase db reset on branch main.`; in a nested worktree it could report the parent checkout's branch. - `stack start --runtime native` on win32 created a stack, failed with `Native artifacts are unsupported on win32/x64`, and left a stopped record that needed `stack destroy`. - `supabase start --help` showed `stack start`'s description and `supabase stack start` examples. ## What changed - The animated spinner runs only when stdout is a TTY; other output gets plain progress lines for each task and its updates. - `stack start` seeds existing buckets without prompting. `db reset` prompt behavior is unchanged. - Git branch detection follows `.git` gitlink files, stops at the nearest `.git`, and returns no branch for a detached HEAD; `db reset` and `db diff` omit the branch clause instead of assuming `main`. - An explicit `--runtime native` is rejected before a stack is created when the platform has no native artifacts. The error is classified as a flags problem rather than Docker not running, and suggests `--runtime docker` or `--runtime podman` (destroying an existing native stack first). - The top-level `start`, `status`, and `stop` aliases have their own descriptions and examples. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../root-stack-aliases.integration.test.ts | 78 +++++++++++++++++++ apps/cli/src/cli/root.ts | 40 +++++++++- .../db-bootstrap/reset-local-database.ts | 13 ++-- .../cli/src/command-internal/stack-runtime.ts | 23 +++++- apps/cli/src/commands/db/diff/diff.handler.ts | 6 +- .../commands/db/diff/diff.integration.test.ts | 6 +- .../db/reset/reset.integration.test.ts | 55 +++++++------ .../stack/prepare/prepare.handler.ts | 2 +- .../experimental/stack/start/SIDE_EFFECTS.md | 6 +- .../experimental/stack/start/start.handler.ts | 4 +- .../stack/start/start.integration.test.ts | 49 +++++++++++- .../src/shared/cli/run.integration.test.ts | 44 +---------- apps/cli/src/shared/git/git-branch.ts | 53 ++++++++++--- .../src/shared/git/git-branch.unit.test.ts | 46 +++++++++++ apps/cli/src/shared/output/output.layer.ts | 12 +++ .../shared/output/output.layer.unit.test.ts | 25 ++++++ apps/cli/tests/helpers/mocks.ts | 43 +++++++++- 17 files changed, 407 insertions(+), 98 deletions(-) create mode 100644 apps/cli/src/cli/root-stack-aliases.integration.test.ts diff --git a/apps/cli/src/cli/root-stack-aliases.integration.test.ts b/apps/cli/src/cli/root-stack-aliases.integration.test.ts new file mode 100644 index 0000000000..2d6f2266d9 --- /dev/null +++ b/apps/cli/src/cli/root-stack-aliases.integration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "@effect/vitest"; +import { Console, Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { emptyEnv, fakeConsole, mockOutput } from "../../tests/helpers/mocks.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; +import { + DebugFlag, + DnsResolverFlag, + ExperimentalFlag, + ProfileFlag, + WorkdirFlag, + YesFlag, +} from "../command-internal/global-flags.ts"; +import { stackStartAliasCommand, stackStatusAliasCommand, stackStopAliasCommand } from "./root.ts"; + +const layerFor = (args: ReadonlyArray, console: Console.Console) => + Layer.mergeAll( + emptyEnv(), + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + Layer.succeed(Console.Console, console), + Layer.succeed(DebugFlag, false), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed(ProfileFlag, "supabase"), + Layer.succeed(WorkdirFlag, Option.none()), + Layer.succeed(YesFlag, false), + Layer.succeed(DnsResolverFlag, "native"), + mockOutput({ format: "text" }).layer, + ); + +// The stack backend aliases `stack start`/`stack status`/`stack stop` to the top-level +// `start`/`status`/`stop` commands (root.ts) — their `--help` text must describe the +// top-level command, not leak the `stack ` invocation it is built from. +describe("stack backend top-level alias help text", () => { + test("`start --help` describes the top-level command, not `stack start`", async () => { + const { console, calls } = fakeConsole(); + await Effect.runPromise( + Effect.scoped( + Command.runWith(stackStartAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide(layerFor(["--help"], console)), + ), + ), + ); + const text = calls.join("\n"); + expect(text).toContain("supabase start"); + expect(text).not.toContain("stack start"); + expect(text).not.toContain("Start a managed local stack"); + }); + + test("`status --help` describes the top-level command, not `stack status`", async () => { + const { console, calls } = fakeConsole(); + await Effect.runPromise( + Effect.scoped( + Command.runWith(stackStatusAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide(layerFor(["--help"], console)), + ), + ), + ); + const text = calls.join("\n"); + expect(text).toContain("supabase status"); + expect(text).not.toContain("stack status"); + }); + + test("`stop --help` describes the top-level command, not `stack stop`", async () => { + const { console, calls } = fakeConsole(); + await Effect.runPromise( + Effect.scoped( + Command.runWith(stackStopAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide(layerFor(["--help"], console)), + ), + ), + ); + const text = calls.join("\n"); + expect(text).toContain("supabase stop"); + expect(text).not.toContain("stack stop"); + }); +}); diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 460cd562b7..058401a9fd 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -76,18 +76,54 @@ import { YesFlag, } from "../command-internal/global-flags.ts"; -const stackStartAliasCommand = stackStartCommand.pipe( +// The stack backend's `start`/`status`/`stop` are the same commands as `stack +// start`/`stack status`/`stack stop`, aliased to the top level — their help text +// is rewritten below so `--help` refers to `supabase start` etc., not `stack start`. +export const stackStartAliasCommand = stackStartCommand.pipe( Command.provide(commandRuntimeLayer(["start"])), Command.provide(stackRuntimeLayer), Command.provide(stackStartRuntimeLayer), + Command.withShortDescription("Start the local Supabase stack"), + Command.withExamples([ + { + command: "supabase start", + description: "Start the current project stack", + }, + { + command: "supabase start --stack feature-a --runtime docker", + description: "Start a named Docker stack", + }, + ]), ); export const stackStopAliasCommand = stackStopCommand.pipe( Command.provide(commandRuntimeLayer(["stop"])), Command.provide(stackRuntimeLayer), + Command.withShortDescription("Stop the local Supabase stack"), + Command.withExamples([ + { + command: "supabase stop --stack feature-a", + description: "Stop the existing feature-a stack", + }, + ]), ); -const stackStatusAliasCommand = stackStatusCommand.pipe( +export const stackStatusAliasCommand = stackStatusCommand.pipe( Command.provide(commandRuntimeLayer(["status"])), Command.provide(stackRuntimeLayer), + Command.withShortDescription("Show the local Supabase stack status"), + Command.withExamples([ + { + command: "supabase status", + description: "Show the current project stack", + }, + { + command: "supabase status --stack feature-a", + description: "Show a named stack", + }, + { + command: "supabase status --env --output-format text > .env.local", + description: "Export connection variables as dotenv", + }, + ]), ); /** Stable builds carry no label; other builds say what they are so help output never claims stability it lacks. */ diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 03472f191c..2cb21499ce 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -12,7 +12,7 @@ import { Data, Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { detectGitBranch } from "../../shared/git/git-branch.ts"; +import { branchClause, detectGitBranch } from "../../shared/git/git-branch.ts"; import { DebugFlag, NetworkIdFlag, @@ -238,9 +238,9 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu workdir, }); }).pipe(Effect.catch((error) => skipSeeding(error.message, suggestionOf(error)))); - const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + const branch = yield* detectGitBranch(workdir); yield* output.raw( - `Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`, + `Finished ${aqua("supabase db reset")}${branchClause(branch, aqua)}.\n`, "stderr", ); return; @@ -345,6 +345,9 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu ); } - const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); - yield* output.raw(`Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`, "stderr"); + const branch = yield* detectGitBranch(workdir); + yield* output.raw( + `Finished ${aqua("supabase db reset")}${branchClause(branch, aqua)}.\n`, + "stderr", + ); }); diff --git a/apps/cli/src/command-internal/stack-runtime.ts b/apps/cli/src/command-internal/stack-runtime.ts index 3894019391..86aa22ef6b 100644 --- a/apps/cli/src/command-internal/stack-runtime.ts +++ b/apps/cli/src/command-internal/stack-runtime.ts @@ -12,13 +12,16 @@ import { /** Runtime that executes a local stack's services. */ export type StackRuntime = "native" | "docker" | "podman"; -/** Raised when automatic selection finds no reachable container engine on a host without native support. */ +/** Raised when no reachable container engine exists or native is requested on an unsupported host. */ export class StackRuntimeSelectionError extends Data.TaggedError("StackRuntimeSelectionError")<{ + readonly reason: "engine-unreachable" | "native-unsupported"; readonly message: string; readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dockerNotRunning; + return this.reason === "native-unsupported" + ? actionability.provideFlags + : actionability.dockerNotRunning; } } @@ -69,7 +72,20 @@ export const automaticRuntimeNotice = ( export const selectStackRuntime = Effect.fn("StackRuntime.select")(function* ( requested: StackRuntime | undefined, ) { - if (requested !== undefined) return requested; + if (requested !== undefined) { + if (requested === "native") { + const { platform, arch } = yield* RuntimeInfo; + // Fail before a stack is created; the runtime's own check only runs mid-start. + if (defaultRuntime({ os: platform, arch }) !== "native") + return yield* new StackRuntimeSelectionError({ + reason: "native-unsupported", + message: `Native artifacts are unsupported on ${platform}/${arch}.`, + suggestion: + "Start Docker or Podman and rerun with --runtime docker or --runtime podman; if this stack already exists as native, run supabase stack destroy first.", + }); + } + return requested; + } const spawner = yield* ChildProcessSpawner; for (const probe of engineProbes) { if (yield* engineReachable(spawner, probe)) return probe.runtime; @@ -77,6 +93,7 @@ export const selectStackRuntime = Effect.fn("StackRuntime.select")(function* ( const { platform, arch } = yield* RuntimeInfo; if (defaultRuntime({ os: platform, arch }) === "native") return "native"; return yield* new StackRuntimeSelectionError({ + reason: "engine-unreachable", message: `Neither Docker nor Podman is reachable, and native stacks are not supported on ${platform}/${arch}.`, suggestion: "Start Docker or Podman, then rerun the command.", }); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index e7832c6886..b5a91d3edd 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -7,7 +7,7 @@ import { NetworkIdFlag, } from "../../../command-internal/global-flags.ts"; import { GoProxy } from "../../../command-internal/go-proxy.service.ts"; -import { detectGitBranch } from "../../../shared/git/git-branch.ts"; +import { branchClause, detectGitBranch } from "../../../shared/git/git-branch.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -730,9 +730,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Detect the branch from the resolved workdir, not the caller's CWD, so // `supabase --workdir … db diff` reports the project's branch, not the // directory the command was invoked from. - const branch = Option.getOrElse(yield* detectGitBranch(cliSettings.workdir), () => "main"); + const branch = yield* detectGitBranch(cliSettings.workdir); yield* output.raw( - `Finished ${aqua("supabase db diff")} on branch ${aqua(branch)}.\n\n`, + `Finished ${aqua("supabase db diff")}${branchClause(branch, aqua)}.\n\n`, "stderr", ); } diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 9cf0c5dbae..dc302f190b 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -544,7 +544,9 @@ describe("db diff", () => { expect(stdout(s.out)).toBe("create table players ();\n\n"); expect(stderr(s.out)).toContain("Creating shadow database..."); expect(stderr(s.out)).toContain("Diffing schemas..."); - expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); + // The temp workdir sits outside any git checkout, so the branch is unknown and + // the "Finished" line omits the clause instead of falsely claiming "main". + expect(stderr(s.out)).toContain("Finished supabase db diff.\n"); expect(s.telemetry.flushed).toBe(true); const expectedHost = FAKE_SHADOW_CONTAINER_ID.slice(0, 12); expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); @@ -562,7 +564,7 @@ describe("db diff", () => { } } expect(sawHost).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(s.layer), (body) => withEnvVar("GITHUB_HEAD_REF", undefined, body)); }); it.effect("forwards SUPABASE_SSL_DEBUG=TRUE to the migra script as true", () => { diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index dca6542b48..4e960d99b1 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -1064,31 +1064,36 @@ describe("db reset", () => { args: ["db", "reset", "--local"], isLocal: true, }); - return Effect.gen(function* () { - yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting local database..."); - expect(out.stderrText).toContain("Recreating database...\n"); - expect(removedContainers(child.spawned)).toContain(DB_ID); - expect(removedVolumes(child.spawned)).toContain(DB_ID); - expect(createArgs(child.spawned)).not.toBeUndefined(); - // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). - expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); - expect(out.stderrText).toContain("Restarting containers...\n"); - // Satellite restarts (storage/auth/realtime/pooler), then Kong reload. - expect(restartedContainers(child.spawned)).toEqual( - expect.arrayContaining([ - "supabase_storage_test", - "supabase_auth_test", - "supabase_realtime_test", - "supabase_pooler_test", - ]), - ); - expect(kongReloadCalls(child.spawned)).toHaveLength(1); - expect(out.stderrText).toContain("Finished "); - expect(out.stderrText).toContain("on branch "); - // Confirms the single `Effect.ensuring` finalizer still fires exactly once. - expect(telemetry.flushCount).toBe(1); - }); + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database..."); + expect(out.stderrText).toContain("Recreating database...\n"); + expect(removedContainers(child.spawned)).toContain(DB_ID); + expect(removedVolumes(child.spawned)).toContain(DB_ID); + expect(createArgs(child.spawned)).not.toBeUndefined(); + // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect(out.stderrText).toContain("Restarting containers...\n"); + // Satellite restarts (storage/auth/realtime/pooler), then Kong reload. + expect(restartedContainers(child.spawned)).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_realtime_test", + "supabase_pooler_test", + ]), + ); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); + // The temp workdir sits outside any git checkout, so the branch is unknown and + // the "Finished" line omits the clause instead of falsely claiming "main". + expect(out.stderrText).toContain("Finished supabase db reset.\n"); + // Confirms the single `Effect.ensuring` finalizer still fires exactly once. + expect(telemetry.flushCount).toBe(1); + }), + ); }); it.live( diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts index 569220115b..52deaefbe0 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -87,7 +87,7 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( Effect.mapError( (error) => new StackCommandPrepareError({ - reason: "runtime", + reason: error.reason === "native-unsupported" ? "flags" : "runtime", message: error.message, suggestion: error.suggestion, cause: error, diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 159f0e1ecb..32343e87db 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -42,7 +42,8 @@ asks the user to start Docker or Podman. When auto selection skips Docker, an in saved Podman or native runtime and how to switch to Docker. An existing stack keeps its saved runtime and runs no probe. Explicit `--runtime docker`, `podman`, or `native` has no fallback. When an explicit or saved Docker runtime is unreachable, the reported failure suggests starting -Docker, and `--runtime native` for a new stack on platforms that support native. +Docker, and `--runtime native` for a new stack on platforms that support native. Explicit +`--runtime native` on a platform with no native artifacts fails before creating a stack. Native startup refuses root because PostgreSQL `initdb` cannot run as root, unless a Claude Code or Modal Sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs @@ -96,7 +97,8 @@ apply needed catalog and webhook setup without replaying project migrations or s composition reapplies the webhook setting before activation. When configured, initial Storage bucket seeding creates buckets and uploads their `objects_path` -files using the service-role JWT. Storage is started and made ready before those requests. A resumed +files using the service-role JWT, silently overwriting or pruning existing buckets without a +confirmation prompt. Storage is started and made ready before those requests. A resumed stack is not re-seeded. Projects without configured buckets make no bucket-seeding requests. A new stack is registered by its owner once that owner starts; if the owner fails to start (for diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 3bebe33d51..f79bdc956e 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -247,7 +247,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags Effect.mapError( (error) => new StackCommandStartError({ - reason: "runtime", + reason: error.reason === "native-unsupported" ? "flags" : "runtime", message: error.message, suggestion: error.suggestion, cause: error, @@ -660,6 +660,8 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags emitSummary: false, interactive: false, yes: true, + // Non-interactive prompts here would fake a `[Y/n]` question nobody answers. + promptless: true, credentials, resolvedConfig: { config: context.config, document: context.loaded?.document }, projectEnvValues: toml.projectEnv, diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 2657600613..c19a3cd187 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -33,7 +33,7 @@ import { mockTelemetryStateTracked, withEnvVar, } from "../../../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { containerEngineSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { DbConnection, @@ -45,7 +45,6 @@ import { CommandPlatformApiFactory } from "../../../../auth/command-platform-api import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer.ts"; import { StackApi, stackApiLayer, StackTargetResolver } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; import { StackCommandStartError } from "./start.errors.ts"; @@ -402,6 +401,8 @@ const layers = ( output = mockOutput(), existing = true, explicitWorkdir = false, + // Fixtures request the native runtime, so pin a host that ships native artifacts. + runtimeInfo = mockRuntimeInfo({ platform: "linux", arch: "x64" }), ) => { const telemetry = mockTelemetryStateTracked(); const target = Layer.succeed(StackTargetResolver, { @@ -421,7 +422,7 @@ const layers = ( }); return Layer.mergeAll( BunServices.layer, - runtimeInfoLayer, + runtimeInfo, output.layer, telemetry.layer, mockCommandSettings({ workdir: root, explicitWorkdir }), @@ -496,6 +497,48 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live( + "rejects --runtime native on a platform with no native artifacts before creating a stack", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-start-native-unsupported-", + }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "unsupported"\n'); + let created = false; + const fixture = fakeStack(); + const base = layers( + root, + fixture, + mockOutput(), + false, + false, + mockRuntimeInfo({ platform: "win32", arch: "x64" }), + ); + const api = Layer.succeed(StackApi, { + create: () => + Effect.sync(() => { + created = true; + return fixture.stack; + }), + open: () => Effect.succeed(fixture.stack), + discover: () => Effect.succeed([]), + find: () => Effect.die("identity not used"), + }); + const result = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.merge(base, api)), + ); + expect(result).toMatchObject({ + reason: "flags", + message: expect.stringContaining("Native artifacts are unsupported on win32/x64"), + }); + expect(created).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("applies capability selection across repeated starts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts index 04cd71dd8d..9aa9075f95 100644 --- a/apps/cli/src/shared/cli/run.integration.test.ts +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -4,7 +4,7 @@ import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { GLOBAL_FLAGS } from "../../command-internal/global-flags.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; -import { emptyEnv, mockOutput } from "../../../tests/helpers/mocks.ts"; +import { emptyEnv, fakeConsole, mockOutput } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "./cli-args.service.ts"; import { OutputFormatFlag } from "./global-flags.ts"; import { exitCodeForFailure, withoutParseErrorHelpDump } from "./run.ts"; @@ -13,44 +13,6 @@ const testBranchesCommand = branchesCommand.pipe( Command.withGlobalFlags([OutputFormatFlag, ...GLOBAL_FLAGS]), ); -/** - * A `Console.Console` test double that records `log`/`error` calls into `calls` instead of - * writing anywhere. Not `vi.spyOn`-based: spying on `console.log` and `console.error` in the same - * test unreliably breaks call detection under this repo's Bun + Vitest combination. - */ -function fakeConsole(): { readonly console: Console.Console; readonly calls: Array } { - const calls: Array = []; - const unused = () => {}; - return { - calls, - console: { - assert: unused, - clear: unused, - count: unused, - countReset: unused, - debug: unused, - dir: unused, - dirxml: unused, - error: (...args: ReadonlyArray) => { - calls.push(`error:${args.join(" ")}`); - }, - group: unused, - groupCollapsed: unused, - groupEnd: unused, - info: unused, - log: (...args: ReadonlyArray) => { - calls.push(`log:${args.join(" ")}`); - }, - table: unused, - time: unused, - timeEnd: unused, - timeLog: unused, - trace: unused, - warn: unused, - }, - }; -} - /** * Runs the real `branchesCommand` definition directly (not nested under `rootCommand`) through * `Command.runWith`, so the `ShowHelp` cause shape is the one the real CLI produces. Bypassing @@ -97,8 +59,8 @@ describe("group command exit codes (CLI-1906)", () => { /** * Runs commands through `Command.runWith`, wrapped in `withoutParseErrorHelpDump`, and asserts on - * the calls recorded by a fake `Console.Console` (see `fakeConsole` above) substituted for the - * real one — the exact service `withoutParseErrorHelpDump` overrides and replays through. + * the calls recorded by `fakeConsole()` substituted for the real `Console.Console` — the exact + * service `withoutParseErrorHelpDump` overrides and replays through. * * `branchesCommand` covers the `UnrecognizedOption` shape end to end. `MissingOption`/ * `InvalidValue` need a genuinely required flag or `Flag.choice`, which every shipped command diff --git a/apps/cli/src/shared/git/git-branch.ts b/apps/cli/src/shared/git/git-branch.ts index 338c23f529..46a5f62b2f 100644 --- a/apps/cli/src/shared/git/git-branch.ts +++ b/apps/cli/src/shared/git/git-branch.ts @@ -4,10 +4,10 @@ import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; /** * Detects the current git branch: `$GITHUB_HEAD_REF` when set (CI - * pull-request workflows), otherwise the nearest `.git/HEAD` walking up from - * `startDir` (default: the runtime CWD), parsed as `ref: refs/heads/`. - * Returns `Option.none()` when no git repository is found; callers substitute - * their own default. + * pull-request workflows), otherwise the nearest `.git` walking up from + * `startDir` (default: the runtime CWD). Returns `Option.none()` when no git + * repository is found, its HEAD is detached, or its branch can't otherwise be + * determined; callers decide how to handle an unknown branch. * * Pass `startDir` explicitly for a resolved `--workdir` so the branch * reflects the project directory, not the process's CWD. @@ -29,15 +29,43 @@ export const detectGitBranch = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const branchFromHead = (content: string): Option.Option => { + const match = content.trim().match(/^ref: refs\/heads\/(.+)$/); + return match?.[1] !== undefined ? Option.some(match[1]) : Option.none(); + }; + + // Reads `HEAD` in `gitDir` and parses it — `Option.none()` covers both a + // missing/unreadable file and a detached HEAD (a raw commit SHA). + const readBranch = (gitDir: string) => + fs + .readFileString(path.join(gitDir, "HEAD")) + .pipe(Effect.option, Effect.map(Option.flatMap(branchFromHead))); + let dir = path.resolve(startDir ?? runtimeInfo.cwd); const root = path.parse(dir).root; while (true) { - const headPath = path.join(dir, ".git", "HEAD"); - const content = yield* fs.readFileString(headPath).pipe(Effect.option); - if (Option.isSome(content)) { - const match = content.value.trim().match(/^ref: refs\/heads\/(.+)$/); - return match?.[1] !== undefined ? Option.some(match[1]) : Option.none(); + const gitPath = path.join(dir, ".git"); + const info = yield* fs.stat(gitPath).pipe(Effect.option); + if (Option.isSome(info)) { + // Found the repository root — resolve its branch here and stop, even on + // failure, rather than walking into an unrelated parent checkout. + if (info.value.type === "Directory") { + return yield* readBranch(gitPath); + } + // A `.git` FILE is a worktree/submodule gitlink: `gitdir: `, the + // path resolved relative to `dir` when it isn't already absolute. + const gitlink = yield* fs.readFileString(gitPath).pipe(Effect.option); + const target = gitlink.pipe( + Option.flatMap((raw) => Option.fromNullishOr(raw.trim().match(/^gitdir:\s*(.+)$/)?.[1])), + ); + if (Option.isNone(target)) { + return Option.none(); + } + const gitDir = path.isAbsolute(target.value) + ? target.value + : path.resolve(dir, target.value); + return yield* readBranch(gitDir); } if (dir === root) { return Option.none(); @@ -45,3 +73,10 @@ export const detectGitBranch = ( dir = path.dirname(dir); } }); + +/** + * Renders " on branch " for a "Finished …" summary line, or an empty + * string when the branch is unknown — never guess a default like `main`. + */ +export const branchClause = (branch: Option.Option, format: (name: string) => string) => + Option.match(branch, { onNone: () => "", onSome: (name) => ` on branch ${format(name)}` }); diff --git a/apps/cli/src/shared/git/git-branch.unit.test.ts b/apps/cli/src/shared/git/git-branch.unit.test.ts index c111eff1ff..511e107fe6 100644 --- a/apps/cli/src/shared/git/git-branch.unit.test.ts +++ b/apps/cli/src/shared/git/git-branch.unit.test.ts @@ -103,4 +103,50 @@ describe("detectGitBranch", () => { expect(got).toEqual(Option.some("project-branch")); }).pipe(Effect.provide(BunServices.layer)), ); + + it.live("resolves a worktree's `.git` gitlink file to its own HEAD", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Mirrors `git worktree add`: the worktree's `.git` is a file pointing at the + // real gitdir under the main checkout's `.git/worktrees/`. + const main = yield* makeTempDir("git-branch-main-"); + yield* writeHead(main, "ref: refs/heads/develop\n"); + const worktreeGitDir = path.join(main, ".git", "worktrees", "feature"); + yield* fs.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fs.writeFileString(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature-x\n"); + const worktree = yield* makeTempDir("git-branch-worktree-"); + yield* fs.writeFileString(path.join(worktree, ".git"), `gitdir: ${worktreeGitDir}\n`); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(worktree))); + expect(got).toEqual(Option.some("feature-x")); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("resolves a relative gitdir in a `.git` gitlink against its directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("git-branch-relative-"); + const gitDir = path.join(root, "actual-gitdir"); + yield* fs.makeDirectory(gitDir, { recursive: true }); + yield* fs.writeFileString(path.join(gitDir, "HEAD"), "ref: refs/heads/relative-branch\n"); + yield* fs.writeFileString(path.join(root, ".git"), "gitdir: ./actual-gitdir\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(got).toEqual(Option.some("relative-branch")); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("stops at the nearest .git instead of a detached HEAD's parent checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("git-branch-detached-nested-"); + yield* writeHead(root, "ref: refs/heads/main\n"); + const nested = path.join(root, "nested"); + yield* fs.makeDirectory(nested); + yield* writeHead(nested, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(nested))); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 41206e92e0..5d6a304a29 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -299,10 +299,18 @@ export const textOutputLayer = Layer.effect( else settle(); }; + // clack's spinner writes cursor/animation escape codes, so non-TTY stdout + // gets plain progress lines instead. + let lastLogged: string | undefined; const show = () => { if (settled) { return; } + if (!tty.stdoutIsTty) { + lastLogged = currentMessage; + log.step(currentMessage); + return; + } shownSpinner = { handle: spinner(), message: currentMessage, pauses: 0 }; shown = true; shownSpinner.handle.start(currentMessage); @@ -326,6 +334,10 @@ export const textOutputLayer = Layer.effect( shownSpinner.message = nextMessage; if (shownSpinner.pauses === 0) shownSpinner.handle.message(formatTaskMessage(nextMessage)); + } else if (lastLogged !== undefined && lastLogged !== nextMessage) { + // Polling tasks repeat the same message; log only changes. + lastLogged = nextMessage; + log.step(nextMessage); } }), succeed: (nextMessage?: string) => diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 69fedd246e..9dc07d7a01 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -736,6 +736,31 @@ describe("Output", () => { }); }); + describe("text layer on a non-TTY stdout", () => { + const layer = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: false }), mockStdio().layer)), + ); + + it.effect("task logs each distinct progress message as a plain line instead of a spinner", () => + Effect.gen(function* () { + vi.useFakeTimers(); + const out = yield* Output; + const task = yield* out.task("Loading organizations..."); + vi.advanceTimersByTime(200); + yield* task.message("Loading projects..."); + yield* task.message("Loading projects..."); + yield* task.succeed("Loaded organizations."); + + expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); + expect(mockClack.log.step.mock.calls).toEqual([ + ["Loading organizations..."], + ["Loading projects..."], + ]); + expect(mockClack.log.success).toHaveBeenCalledWith("Loaded organizations."); + }).pipe(Effect.provide(layer)), + ); + }); + describe("json layer", () => { it.effect("interactive is false", () => { const mock = mockStdio(); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d6c4ff3765..9d789e5be5 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -2,7 +2,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; -import { ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; +import { Console, ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; import { cliSettingsLayer } from "../../src/shared/config/cli-settings.layer.ts"; import { CliProjectHome } from "../../src/shared/config/cli-project-home.service.ts"; @@ -703,3 +703,44 @@ export function emptyEnv() { ), ); } + +/** + * A `Console.Console` test double recording `log`/`error` calls — `--help` and parse-error + * output render through it. Not `vi.spyOn`-based: spying on `console.*` here unreliably breaks + * call detection under this repo's Bun + Vitest combination. + */ +export function fakeConsole(): { + readonly console: Console.Console; + readonly calls: Array; +} { + const calls: Array = []; + const unused = () => {}; + return { + calls, + console: { + assert: unused, + clear: unused, + count: unused, + countReset: unused, + debug: unused, + dir: unused, + dirxml: unused, + error: (...args: ReadonlyArray) => { + calls.push(`error:${args.join(" ")}`); + }, + group: unused, + groupCollapsed: unused, + groupEnd: unused, + info: unused, + log: (...args: ReadonlyArray) => { + calls.push(`log:${args.join(" ")}`); + }, + table: unused, + time: unused, + timeEnd: unused, + timeLog: unused, + trace: unused, + warn: unused, + }, + }; +} From 87d9efbb28f13a7093c82c2ced0a4ff5fc3090dd Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 12:41:00 +0000 Subject: [PATCH 53/71] fix(stack): reach stack services from Windows and Docker Desktop containers (#6887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR Three experimental `stack` backend bugs block Windows and Docker Desktop users: every Edge Function returns 404 on Windows, Realtime `postgres_changes` never connect on Docker Desktop, and `db dump --db-url` cannot use the `DB_URL` that `stack status` prints. This PR makes container paths POSIX, gives stack containers an IPv4-only host alias, and rewrites loopback database URLs for tool containers. ## Before ```mermaid flowchart LR A["Realtime tenant connection"] -->|"resolves host.docker.internal"| B["IPv6 address first"] B -->|refused| C["Host listener
IPv4 only"] D["Windows Functions root"] --> E["\__supabase_project\..."] --> F["404 Function not found"] G["db dump --db-url 127.0.0.1"] --> H["pg_dump container loopback"] --> I["Connection refused"] ``` ## After ```mermaid flowchart LR A["Realtime tenant connection"] -->|"resolves host.supabase.internal"| B["IPv4 host gateway"] B --> C["Host listener
IPv4 only"] D["Windows Functions root"] --> E["/__supabase_project/..."] --> F["Function served"] G["db dump --db-url 127.0.0.1"] --> H["rewritten to host.docker.internal"] --> I["Host-published stack port"] ``` ## Why - On Windows the Edge Runtime receives `SUPABASE_INTERNAL_FUNCTIONS_ROOT=\__supabase_project\supabase\functions`; the bootstrap only accepts `/`-rooted paths, so every function answers `404 Function not found`. - On Docker Desktop, `host.docker.internal` resolves to IPv6 first inside containers while the stack host listener is IPv4-only. `DB_IP_VERSION=ipv4` only covers Realtime's own repo; tenant CDC connections probe IPv6 first, so channel joins fail with `UnableToConnectToProject`. - `db dump --db-url "postgresql://…@127.0.0.1:/postgres"` runs `pg_dump` in a container where `127.0.0.1` is the container itself. The loopback rewrite only applied when the port matched `config.toml`, which dynamic stack ports never do. ## What changed - Edge Functions container paths (functions root, files root, entrypoints, import maps, static files) are built as POSIX paths regardless of the host OS. - Docker stack containers get a `host.supabase.internal` alias, used as the Docker runtime address for service-to-service URLs. Docker Desktop maps `host-gateway` to both IPv4 and IPv6, so each stack host probes the engine's IPv4 host gateway once and maps the alias to it explicitly. The probe runs in the background while the database starts (the database keeps `host-gateway`, since it never needs the IPv4-only alias); other services wait for the shared result, retry a failed probe once, and fall back to `host-gateway` when no IPv4 is found. Engines that reject `host-gateway` in `--add-host` (such as older Podman behind the Docker socket) get the IPv4 they map to `host.docker.internal`/`host.containers.internal`, or an actionable error when there is none. Linux keeps the existing `host.docker.internal` mapping; Podman and native runtimes are unchanged. - `db dump` rewrites loopback `--db-url` targets for non-managed stack targets and for tool containers on a named network. Managed `--local` stack dumps are unchanged. - The same loopback rewrite applies to the `db diff` migra and pgAdmin containers and to the `db pull` initial schema dump. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/src/command-internal/db-pull-run.ts | 27 +- apps/cli/src/command-internal/pg-dump.run.ts | 3 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 8 +- apps/cli/src/commands/db/diff/diff.handler.ts | 15 +- .../commands/db/diff/diff.integration.test.ts | 24 +- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 10 +- apps/cli/src/commands/db/dump/dump.handler.ts | 12 +- .../commands/db/dump/dump.integration.test.ts | 40 ++ apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 8 +- .../commands/db/pull/pull.integration.test.ts | 38 +- apps/cli/src/commands/db/shared/migra.ts | 34 +- .../stack/src/Network.integration.test.ts | 24 + packages/stack/src/Network.ts | 3 +- packages/stack/src/Owner.ts | 3 + packages/stack/src/StackHost.ts | 4 + packages/stack/src/host/CommandRunner.ts | 7 +- .../src/runtime/Container.integration.test.ts | 443 +++++++++++++++++- packages/stack/src/runtime/Container.ts | 199 +++++++- .../stack/src/runtime/Container.unit.test.ts | 99 ++++ packages/stack/src/services/Catalog.ts | 2 + packages/stack/src/services/Database.ts | 6 + .../services/Functions.integration.test.ts | 101 +++- packages/stack/src/services/Functions.ts | 18 +- packages/stack/src/services/Realtime.ts | 4 +- packages/stack/src/services/Recipe.ts | 3 + 25 files changed, 1090 insertions(+), 45 deletions(-) create mode 100644 packages/stack/src/runtime/Container.unit.test.ts diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 25104e1d09..536dfdbd6d 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -46,7 +46,11 @@ import { import { diffMigra } from "../commands/db/shared/migra.ts"; import { writePgDeltaMigrations } from "../commands/db/shared/pgdelta-migrations.write.ts"; import { type DumpOptions, buildSchemaDumpEnv } from "./pg-dump.env.ts"; -import { streamPgDumpWithClient } from "./pg-dump.run.ts"; +import { dumpNetworkMode, streamPgDumpWithClient } from "./pg-dump.run.ts"; +import { + rewriteDumpHostForToolContainer, + toolContainerUsesHostNetwork, +} from "./postgres-client.run.ts"; import { emitPoolerFallbackWarning, isDirectLinkedHost, @@ -492,6 +496,24 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( message: `failed to open dump file: ${cause.message}`, fileOpen: true, }); + const stackBackend = (yield* currentStackBackend).kind === "stack"; + const seedNetwork = dumpNetworkMode( + Option.getOrUndefined(networkIdFlag), + stackBackend, + projectEnv, + ); + const seedUsesHostNetwork = + seedNetwork._tag === "host" || toolContainerUsesHostNetwork(seedNetwork.name); + const seedDumpConn = (target: PgConnInput): PgConnInput => + stackBackend || !seedUsesHostNetwork + ? { + ...target, + host: rewriteDumpHostForToolContainer(target.host, { + platform: runtimeInfo.platform, + usesHostNetwork: seedUsesHostNetwork, + }), + } + : target; // Stream pg_dump → migration file, (re)truncating per attempt so a pooler // retry leaves only the successful attempt's bytes. const runSchemaDump = (target: PgConnInput) => { @@ -513,9 +535,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( return yield* streamPgDumpWithClient({ image, script: dumpSchemaScript, - env: buildSchemaDumpEnv(target, dumpEnvOpt), + env: buildSchemaDumpEnv(seedDumpConn(target), dumpEnvOpt), projectEnvValues: projectEnv, client: { kind: "container" }, + forceHostNetwork: stackBackend, onStdout: (chunk) => { if (chunk.length > 0) seedWroteBytes = true; return file.writeAll(chunk).pipe( diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index d79aa107d2..8e50a6cf6c 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -105,7 +105,8 @@ export const pgDumpClientExitMessage = (client: PgDumpClient, exitCode: number): ? `error running ${client.command}: exit ${exitCode}` : `error running container: exit ${exitCode}`; -const dumpNetworkMode = ( +/** Network for a pg_dump tool container; host unless `--network-id` or `SUPABASE_NETWORK_ID` names one. */ +export const dumpNetworkMode = ( networkId: string | undefined, forceHostNetwork: boolean, projectEnvValues: Readonly>, diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index d75d71fb88..4eb11d7469 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -57,7 +57,10 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## Docker -- Edge-runtime container (migra engine only). +- Edge-runtime container (migra engine only). It and the `supabase/migra` fallback get + loopback `SOURCE`/`TARGET` hosts rewritten to `host.docker.internal` when `--network-id` + puts them on a named network, or when a stack-backend URL is used outside Linux host + networking. - Shadow Postgres container — provisioned and torn down natively (`prepareShadowSource` in `commands/db/shared/shadow-source.ts`, over the lower-level primitives in `command-internal/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Explicit @@ -77,7 +80,8 @@ it, and JSON `null` disables formatting without disabling safe compaction. (`dockerfileServiceImage("differ")`). One `docker run --rm` when no `--schema` is given; one run per `--schema` value, in flag order. Runs on the project's Docker network (`--network-id` or the generated `supabase_network_` — never the host network, unlike the migra - bash fallback), with `--add-host host.docker.internal:host-gateway` on Linux only, and both + bash fallback), so loopback source and shadow hosts become `host.docker.internal` unless + `--network-id host` is set, with `--add-host host.docker.internal:host-gateway` on Linux only, and both `com.supabase.cli.project`/`com.docker.compose.project` labels — no env vars, bind mounts, or working-directory override. diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index b5a91d3edd..a92de2a624 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -23,6 +23,10 @@ import type { DbConnType } from "../../../command-internal/db-target-flags.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import { makeDir } from "../../../command-internal/make-dir.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + rewriteDumpHostForToolContainer, + toolContainerUsesHostNetwork, +} from "../../../command-internal/postgres-client.run.ts"; import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; import { schemaToCsvField } from "../../../command-internal/schema-flags.ts"; import { findDropStatements } from "../../../command-internal/sql-split.ts"; @@ -625,13 +629,20 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { setup: shadowBase.setup, }); yield* emitStatus("Diffing local database with current migrations..."); + const differHost = (host: string) => + toolContainerUsesHostNetwork(shadowBase.networkId) + ? host + : rewriteDumpHostForToolContainer(host, { + platform: runtimeInfo.platform, + usesHostNetwork: false, + }); return yield* diffSchemaPgAdmin({ // `source`/`target` are inverted relative to the migra/pg-delta path below: // `source` is the user's db, `target` is the shadow. - source: targetUrl, + source: toPostgresURL({ ...resolved.conn, host: differHost(resolved.conn.host) }), // Hardcoded, not built via `toPostgresURL`: this ignores // `SUPABASE_SERVICES_HOSTNAME`/`[db] password` by design, not a bug to fix. - target: `postgresql://postgres:postgres@127.0.0.1:${shadowBase.shadowPort}/postgres`, + target: `postgresql://postgres:postgres@${differHost("127.0.0.1")}:${shadowBase.shadowPort}/postgres`, schema: flags.schema, projectEnvValues: cfg.projectEnv, projectId: shadowBase.projectId, diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index dc302f190b..7f6badf1c4 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -529,10 +529,10 @@ function pgadminEntry(overrides: Record = {}) { const PGADMIN_DIFF_SQL = `${PGADMIN_DIFF_HEADER}\n\nALTER TABLE test;\n`; // Matches `setup()`'s default resolver/shadow-port fixtures (conn 127.0.0.1:54322, -// shadow port 54320). +// shadow port 54320), as seen from the differ's project network. const PGADMIN_SOURCE_URL = - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10"; -const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@127.0.0.1:54320/postgres"; + "postgresql://postgres:postgres@host.docker.internal:54322/postgres?connect_timeout=10"; +const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@host.docker.internal:54320/postgres"; describe("db diff", () => { it.effect("diffs local with the default migra engine and prints SQL to stdout", () => { @@ -1938,6 +1938,24 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "the migra OOM fallback on a named network targets loopback databases via the host", + () => { + const s = setup(tmp.current, { + oom: true, + diffSql: "create table fb ();\n", + isLocal: true, + networkId: "my-net", + }); + return Effect.gen(function* () { + yield* dbDiff(flags({ schema: ["public"] })); + const env = (s.dockerCalls[0] as { env: Readonly> }).env; + expect(new URL(env["SOURCE"] ?? "").hostname).toBe("host.docker.internal"); + expect(new URL(env["TARGET"] ?? "").host).toBe("host.docker.internal:54322"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.live( "removes the shadow container on a SIGINT-style interruption during the readiness wait, without waiting for the readiness timeout", () => { diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 17265bf83c..ba27b252c2 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -105,7 +105,9 @@ shell inherits the suppressing variables and is missed. - **Stack backend host rewrite.** Stack dumps always use catalog `pg_dump` (native artifact or a one-shot of the same image). `--local` native rewrites loopback to `127.0.0.1`. Native `--linked` / `--db-url` keep - the resolved host. Container dumps use `isLocal` (config host+port match), not a - `connType` of local: a `--db-url` that matches `config.toml` is rewritten like a published - stack target (`host.docker.internal` / host network) and does not require a - project stack. Engine/runtime selection still uses `connType`. + the resolved host. Managed `--local` dumps run through the stack's own command runtime + against its endpoint without a rewrite. Other container dumps rewrite any loopback host + (whatever its port) to `host.docker.internal` unless the tool container shares the Linux + host network, and always run on the host network unless `--network-id` is set. Legacy + container dumps rewrite loopback only when `--network-id` / `SUPABASE_NETWORK_ID` puts + pg_dump on a named network. Engine/runtime selection still uses `connType`. diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 6d068f9165..0d92fec4fa 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -220,12 +220,16 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { ? undefined : envNetworkId, ); - const stackPublishedTarget = backend.kind === "stack" && isLocal && managedStack === undefined; + // Loopback inside a bridge-networked tool container is the container itself; stack + // targets are published by a host-side proxy that the Docker VM loopback never sees. + // A managed stack runs pg_dump through its own command runtime against its own endpoint. + const rewriteLoopbackTarget = + backend.kind === "stack" ? managedStack === undefined : !dumpUsesHostNetwork; const dumpConn = useNativeClient ? connType === "local" ? dumpConnForHostClient(conn) : conn - : stackPublishedTarget + : rewriteLoopbackTarget ? { ...conn, host: rewriteDumpHostForToolContainer(conn.host, { @@ -373,7 +377,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), projectEnvValues: projectEnv, client: dumpClient, - forceHostNetwork: stackPublishedTarget, + forceHostNetwork: backend.kind === "stack", }); }), ), @@ -397,7 +401,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { : (chunk) => output.rawBytes(chunk), projectEnvValues: projectEnv, client: dumpClient, - forceHostNetwork: stackPublishedTarget, + forceHostNetwork: backend.kind === "stack", }); // 7b. IPv6 → IPv4-pooler retry, shared with `db pull`: a linked dump can reach the diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index bcd3872bbe..af9f38fc3b 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -808,6 +808,46 @@ describe("db dump integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); + it.live("points a Windows tool container at the host for a loopback stack db-url", () => { + const conn = { + host: "127.0.0.1", + port: 55432, + user: "postgres", + password: "postgres", + database: "postgres", + }; + const { layer, bundled } = setup({ conn, isLocal: false, platform: "win32", stdout: "" }); + return Effect.gen(function* () { + yield* dbDump( + flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + ); + expect(bundled.lastOpts?.runtime).toEqual({ kind: "container", engine: "docker" }); + expect(bundled.lastOpts?.network).toBe("host"); + expect(bundled.lastOpts?.env).toMatchObject({ + PGHOST: "host.docker.internal", + PGPORT: "55432", + }); + }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); + }); + + it.live("points a named-network legacy tool container at the host for a loopback db-url", () => { + const { layer, docker } = setup({ + conn: { ...LOCAL_CONN, port: 55432 }, + isLocal: false, + networkId: "custom_net", + }); + return Effect.gen(function* () { + yield* dbDump( + flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + ); + expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); + expect(docker.lastOpts?.env).toMatchObject({ + PGHOST: "host.docker.internal", + PGPORT: "55432", + }); + }).pipe(Effect.provide(layer)); + }); + it.live("caches the linked project even when connection resolution fails (Go PostRun)", () => { // The project ref is resolved before the connection is built, and the // linked-project cache is refreshed unconditionally afterward. So an diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 01499ce47e..07ae92efc8 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -74,8 +74,10 @@ disables formatting without disabling safe compaction. below. Migration-style pulls only; `--declarative` provisions no shadow. - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump - (`streamPgDumpWithClient`, shared with `db dump`). Stack-backed pulls skip dump - seeding: they always use pg-delta and reject `--diff-engine migra`. + (`streamPgDumpWithClient`, shared with `db dump`). It runs on the host network unless + `--network-id` / `SUPABASE_NETWORK_ID` names another network; on a named network a loopback + target is rewritten to `host.docker.internal`. Stack-backed pulls skip dump seeding: they + always use pg-delta and reject `--diff-engine migra`. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) @@ -127,7 +129,7 @@ at all, so nothing is cached for it. | `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | | `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | | `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow and initial-migra `pg_dump` containers onto an existing Docker network | no | | `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres, `pg_dump`, PG15+ realtime/storage/auth migrate-job images (migration-style cold shadow), and (for migra) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, and `deno_version = 1` stay on docker.io | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline state is saved under a managed snapshot key and restored into the next run's fresh stack database (see Notes) | no | diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 5ef00703ed..e6f6b6b7b0 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -124,6 +124,8 @@ interface SetupOpts { // Raw argv seen by the handler (CliArgs). Only consulted when both `--declarative` // and `--use-pg-delta` are present, to replay pflag's last-occurrence-wins ordering. readonly args?: ReadonlyArray; + readonly networkId?: string; + readonly platform?: NodeJS.Platform; // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass // `Option.none()` to exercise the config.toml/workdir-basename fallback // (`resolveLocalProjectId`). @@ -483,13 +485,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(ExperimentalFlag, opts.experimental ?? false), Layer.succeed(DebugFlag, false), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(NetworkIdFlag, Option.fromUndefinedOr(opts.networkId)), Layer.succeed(PgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), - mockRuntimeInfo(), + mockRuntimeInfo(opts.platform === undefined ? {} : { platform: opts.platform }), workdirFiles, ); return { @@ -1624,6 +1626,38 @@ describe("db pull", () => { }, ); + it.effect("points a named-network pg_dump container at the host for a loopback target", () => { + const s = setup(tmp.current, { + files: { + "supabase/.env": "SUPABASE_NETWORK_ID=dotenv-net\n", + }, + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* dbPull(flags({ local: Option.some(true) })); + expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); + expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("host.docker.internal"); + }).pipe(Effect.provide(s.layer), (body) => withEnvVar("SUPABASE_NETWORK_ID", undefined, body)); + }); + + it.effect("keeps a loopback target for a Linux pg_dump container on --network-id host", () => { + const s = setup(tmp.current, { + networkId: "host", + platform: "linux", + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* dbPull(flags({ local: Option.some(true) })); + expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("127.0.0.1"); + }).pipe(Effect.provide(s.layer), (body) => withEnvVar("SUPABASE_NETWORK_ID", undefined, body)); + }); + it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { // An explicit `--yes=false` wins over the SUPABASE_YES env — a piped `n` still // declines the history update rather than auto-confirming. diff --git a/apps/cli/src/commands/db/shared/migra.ts b/apps/cli/src/commands/db/shared/migra.ts index 015a163def..0b7aa47d25 100644 --- a/apps/cli/src/commands/db/shared/migra.ts +++ b/apps/cli/src/commands/db/shared/migra.ts @@ -11,6 +11,11 @@ import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { EdgeRuntimeScript } from "../../../command-internal/edge-runtime-script.service.ts"; import { PG_DELTA_CA_BUNDLE } from "../../../command-internal/pgdelta-ssl.ts"; import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; +import { + rewriteDumpHostForToolContainer, + toolContainerUsesHostNetwork, +} from "../../../command-internal/postgres-client.run.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { migraDiffScript, migraDiffShellScript } from "./migra.deno-templates.ts"; import { MigraDiffError, MigraSchemaLoadError } from "./migra.errors.ts"; import { edgeRuntimeId, type PgDeltaContext } from "../../../command-internal/pgdelta.ts"; @@ -102,6 +107,26 @@ function shouldFallbackToBashMigra(message: string): boolean { ); } +/** + * Rewrites a loopback database URL for the migra container, which runs on the host network + * unless `--network-id` is set. Stack databases are published by a host-side proxy that + * Docker Desktop's host network does not share. + */ +const containerDatabaseUrl = Effect.fnUntraced(function* (url: string) { + const runtimeInfo = yield* RuntimeInfo; + const usesHostNetwork = toolContainerUsesHostNetwork(Option.getOrUndefined(yield* NetworkIdFlag)); + if (usesHostNetwork && (yield* currentStackBackend).kind !== "stack") return url; + const parsed = URL.parse(url); + if (parsed === null) return url; + const host = rewriteDumpHostForToolContainer(parsed.hostname, { + platform: runtimeInfo.platform, + usesHostNetwork, + }); + if (host === parsed.hostname) return url; + parsed.hostname = host; + return parsed.href; +}); + /** Builds the shared SOURCE/TARGET/SSL/schema env for both migra paths. */ const buildMigraEnv = Effect.fnUntraced(function* (params: { readonly source: string; @@ -110,8 +135,8 @@ const buildMigraEnv = Effect.fnUntraced(function* (params: { }) { const probe = yield* PgDeltaSslProbe; const env: Record = { - SOURCE: params.source, - TARGET: params.target, + SOURCE: yield* containerDatabaseUrl(params.source), + TARGET: yield* containerDatabaseUrl(params.target), }; if (yield* isSslDebugEnabled) env["SUPABASE_SSL_DEBUG"] = "true"; // Probe the target for TLS; if it speaks TLS, inject the embedded CA bundle as SSL_CA. @@ -175,7 +200,10 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { params.schema.length > 0 ? params.schema : yield* loadTargetUserSchemas(params.target, params.connectOptions); - const env: Record = { SOURCE: params.source, TARGET: params.target }; + const env: Record = { + SOURCE: yield* containerDatabaseUrl(params.source), + TARGET: yield* containerDatabaseUrl(params.target), + }; if (yield* isSslDebugEnabled) env["SUPABASE_SSL_DEBUG"] = "true"; // The script runs as a string, so command-line args must be set manually via `set --` // for migra.sh's `"$@"` loop to see the schema list. diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index 6d2c97049c..47fb745cb9 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -5,6 +5,7 @@ import * as Net from "node:net"; // oxlint-disable-line effecttsgo/node-builtin- import { createServer } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. import { HttpClient } from "effect/unstable/http"; import * as Network from "./Network.ts"; +import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; import * as State from "./State.ts"; const makeTestState = (root: string) => @@ -321,3 +322,26 @@ it.live("releases dedicated HTTP activity after the response while keep-alive st }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.live("addresses docker runtime endpoints through the stack host alias", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-docker-alias-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "docker", state }); + const namespace = yield* network.register({ + id: "one", + endpoints: { api: endpoint(target, Effect.succeed(false)) }, + }); + yield* namespace.bind; + const host = yield* namespace.address("api", "host"); + const runtime = yield* namespace.address("api", "runtime"); + expect(host.host).toBe("127.0.0.1"); + expect(runtime).toEqual({ ...host, host: DOCKER_HOST_ALIAS }); + yield* namespace.release; + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index f47b9a6274..bb945595fe 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -1,4 +1,5 @@ import { Context, Data, Effect, Exit, Layer, Ref, Scope, Semaphore } from "effect"; +import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; import * as State from "./State.ts"; import { makePorts, PortError } from "./Ports.ts"; import { bindTcp, serveTcp, type BackendAddress, type ProxyError } from "./Proxy.ts"; @@ -84,7 +85,7 @@ const makeNetwork = (options: { options.runtime === "native" ? "127.0.0.1" : options.runtime === "docker" - ? "host.docker.internal" + ? DOCKER_HOST_ALIAS : "host.containers.internal"; const register = Effect.fn("Network.register")(function* ({ diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index 7dcfd4cf47..a47a685e8f 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -72,6 +72,8 @@ export interface OwnerOptions { readonly state: State.Interface; readonly root: string; readonly cacheRoot: string; + /** Shares one host-gateway probe with the host's other container runtimes. */ + readonly hostGateway?: Container.HostGateway; } type OwnerRpcs = RpcGroup.Rpcs; @@ -293,6 +295,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { cacheRoot: options.cacheRoot, runtime, helpers, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }).pipe(Effect.provideContext(services)); const persistCreation = (entry: Pick, creation: ServiceCreation) => diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index cf6928c543..e2571409c3 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -41,6 +41,7 @@ import { import { projectSegmentFor } from "./identity/Identity.ts"; import * as Owner from "./Owner.ts"; import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; +import { makeHostGateway } from "./runtime/Container.ts"; import * as State from "./State.ts"; import { sweepOrphans } from "./Sweep.ts"; import { makeCommandAttachments } from "./host/CommandAttachments.ts"; @@ -436,12 +437,14 @@ export const runStackHost = Effect.fn("StackHost.run")( ), ), ); + const hostGateway = yield* makeHostGateway; const services = yield* Layer.build( Layer.merge( Owner.layer({ saved, root: dataRoot, cacheRoot: options.cacheRoot, + hostGateway, }), CommandRunner.layer({ stackId: saved.id, @@ -449,6 +452,7 @@ export const runStackHost = Effect.fn("StackHost.run")( root: dataRoot, cacheRoot: options.cacheRoot, runtime: saved.runtime, + hostGateway, }), ).pipe(Layer.provide(Layer.succeed(State.Service, state))), ); diff --git a/packages/stack/src/host/CommandRunner.ts b/packages/stack/src/host/CommandRunner.ts index c88eb60229..1a318c2892 100644 --- a/packages/stack/src/host/CommandRunner.ts +++ b/packages/stack/src/host/CommandRunner.ts @@ -21,7 +21,7 @@ import { postgresVersion, resolveArtifact, } from "../Artifacts.ts"; -import { makeContainerRuntime } from "../runtime/Container.ts"; +import { makeContainerRuntime, type HostGateway } from "../runtime/Container.ts"; import { spawnNativeProcess } from "../runtime/NativeProcess.ts"; import { awaitCommandOutput, type CommandOutputResult } from "../runtime/CommandOutput.ts"; import type { CommandInvocation as CommandInvocationType } from "../Commands.ts"; @@ -75,6 +75,8 @@ const makeCommandRunner = (options: { readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; + /** Shares one host-gateway probe with the host's other container runtimes. */ + readonly hostGateway?: HostGateway; }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -90,6 +92,7 @@ const makeCommandRunner = (options: { engine: options.runtime, root: options.root, imageMirrors: slimImageMirrors, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }); const jobsRoot = path.join(options.root, "jobs"); yield* fs @@ -306,4 +309,6 @@ export const layer = (options: { readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; + /** Shares one host-gateway probe with the host's other container runtimes. */ + readonly hostGateway?: HostGateway; }) => Layer.effect(Service, makeCommandRunner(options).pipe(Effect.map(Service.of))); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 55aaad7494..b724055684 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -21,7 +21,13 @@ import { TestClock } from "effect/testing"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import { HttpClient } from "effect/unstable/http"; -import { ContainerLaunchError, makeContainerRuntime, type ContainerProcess } from "./Container.ts"; +import { + ContainerLaunchError, + DOCKER_HOST_ALIAS, + makeContainerRuntime, + makeHostGateway, + type ContainerProcess, +} from "./Container.ts"; const image = await Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -30,6 +36,7 @@ const image = await Effect.gen(function* () { const version = yield* fs.readFileString(file); return `oven/bun:${version.trim()}-slim`; }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); +const ipv4 = /^(?:\d{1,3}\.){3}\d{1,3}$/u; const stoppableIdleScript = "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"; @@ -148,6 +155,376 @@ describe("container process adapter", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("maps the stack host alias to an explicit IPv4 host gateway only", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias", + env: {}, + args: ["-e", "console.log(await Bun.file('/etc/hosts').text())"], + }); + const [hosts, exitCode] = yield* Effect.all( + [process.stdout.pipe(Stream.decodeText, Stream.mkString), process.exitCode], + { concurrency: "unbounded" }, + ); + expect(exitCode).toBe(0); + const addresses = hosts + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .filter(([, ...names]) => names.includes(DOCKER_HOST_ALIAS)) + .map(([address]) => address ?? ""); + expect(addresses.length).toBeGreaterThan(0); + expect(addresses.every((address) => ipv4.test(address))).toBe(true); + expect(yield* inspectExtraHosts(process.id)).toContain( + `${DOCKER_HOST_ALIAS}:${addresses[0]}`, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("probes the host gateway once for concurrent launches across runtimes", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const hostGateway = yield* makeHostGateway; + const makeRuntime = makeContainerRuntime({ engine: "docker", root: ".", hostGateway }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, () => undefined), + ), + ); + const first = yield* makeRuntime; + const second = yield* makeRuntime; + yield* first.prepare(image); + const processes = yield* Effect.all( + [first, second].map((runtime, index) => + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: `host-alias-concurrent-${index}`, + env: {}, + args: ["-e", "process.exit(0)"], + }), + ), + { concurrency: "unbounded" }, + ); + expect(yield* Ref.get(probes)).toBe(1); + for (const process of processes) { + const aliases = (yield* inspectExtraHosts(process.id)).filter((entry) => + entry.startsWith(`${DOCKER_HOST_ALIAS}:`), + ); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.slice(DOCKER_HOST_ALIAS.length + 1)).toMatch(ipv4); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("launches without awaiting a background probe that later launches share", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const release = yield* Deferred.make(); + const hostGateway = yield* makeHostGateway; + const spawner = makeHostGatewayProbeSpawner(delegate, probes, () => undefined, release); + const database = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway, + awaitHostGateway: false, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + const service = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + yield* database.prepare(image); + const launchExit = (runtime: typeof service, instanceId: string) => + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }); + + const early = yield* launchExit(database, "host-alias-background"); + expect(yield* inspectExtraHosts(early.id)).toContain(`${DOCKER_HOST_ALIAS}:host-gateway`); + const waiting = yield* launchExit(service, "host-alias-awaiting").pipe(Effect.forkChild); + yield* Deferred.succeed(release, undefined); + const later = yield* Fiber.join(waiting); + + const aliases = (yield* inspectExtraHosts(later.id)).filter((entry) => + entry.startsWith(`${DOCKER_HOST_ALIAS}:`), + ); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.slice(DOCKER_HOST_ALIAS.length + 1)).toMatch(ipv4); + expect(yield* Ref.get(probes)).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("maps the host alias to the IPv4 gateway listed after an IPv6 one", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner( + delegate, + yield* Ref.make(0), + () => + `console.log("fdc4:f303:9324::254\\t${DOCKER_HOST_ALIAS}\\n192.168.65.254\\t${DOCKER_HOST_ALIAS}")`, + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-dual-stack", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain( + `${DOCKER_HOST_ALIAS}:192.168.65.254`, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("falls back to host-gateway without reprobing when the gateway has no IPv4 address", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner( + delegate, + probes, + () => `console.log("fdc4:f303:9324::254\\t${DOCKER_HOST_ALIAS}")`, + ), + ), + ); + yield* runtime.prepare(image); + for (const instanceId of ["host-alias-fallback-a", "host-alias-fallback-b"]) { + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain( + `${DOCKER_HOST_ALIAS}:host-gateway`, + ); + } + expect(yield* Ref.get(probes)).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retries a probe whose image lacks cat before launching", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 1 + ? `console.error('exec: "cat": executable file not found in $PATH'); process.exit(127)` + : undefined, + ), + ), + ); + yield* runtime.prepare(image); + const launchExit = (instanceId: string) => + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }); + const launched = yield* launchExit("host-alias-retried-probe"); + const aliases = (yield* inspectExtraHosts(launched.id)).filter((entry) => + entry.startsWith(`${DOCKER_HOST_ALIAS}:`), + ); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.slice(DOCKER_HOST_ALIAS.length + 1)).toMatch(ipv4); + expect(yield* Ref.get(probes)).toBe(2); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("maps the host alias to the engine's own host address when it rejects host-gateway", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 1 ? hostGatewayRejectionScript : podmanHostsScript, + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-engine-host", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain(`${DOCKER_HOST_ALIAS}:10.88.0.1`); + expect(yield* Ref.get(probes)).toBe(2); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retries the engine host probe after it fails transiently", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 2 + ? `console.error("daemon busy"); process.exit(1)` + : attempt === 4 + ? podmanHostsScript + : hostGatewayRejectionScript, + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-engine-host-retry", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain(`${DOCKER_HOST_ALIAS}:10.88.0.1`); + expect(yield* Ref.get(probes)).toBe(4); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fails launches actionably when an engine rejecting host-gateway maps no IPv4 host", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 1 + ? hostGatewayRejectionScript + : `console.log("127.0.0.1\\tlocalhost\\n::1\\thost.containers.internal")`, + ), + ), + ); + yield* runtime.prepare(image); + for (const instanceId of ["host-alias-unsupported-a", "host-alias-unsupported-b"]) { + const failure = yield* Effect.scoped( + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }), + ).pipe(Effect.flip); + expect(failure._tag).toBe("ContainerError"); + expect(failure.message).toContain("upgrade Podman or use --runtime podman"); + } + expect(yield* Ref.get(probes)).toBe(2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("recreates an unawaited launch the engine rejects for host-gateway", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const database = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + awaitHostGateway: false, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner( + makeHostGatewayRejectingCreateSpawner(delegate), + probes, + (attempt) => (attempt === 1 ? hostGatewayRejectionScript : podmanHostsScript), + ), + ), + ); + yield* database.prepare(image); + const process = yield* database.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-recreated", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain(`${DOCKER_HOST_ALIAS}:10.88.0.1`); + expect(yield* Ref.get(probes)).toBe(2); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("names and labels a service container for compose-style grouping", () => Effect.scoped( Effect.gen(function* () { @@ -953,6 +1330,56 @@ const makePullFailureSpawner = ( return delegate.spawn(command); }); +const hostGatewayRejectionScript = `console.error(${JSON.stringify( + 'Error response from daemon: invalid IP address in add-host: "host-gateway"', +)}); process.exit(125)`; + +const podmanHostsScript = `console.log("10.88.0.1\\thost.containers.internal host.docker.internal")`; + +/** Rejects a `docker create` that maps the host alias to `host-gateway`, as older Podman does. */ +const makeHostGatewayRejectingCreateSpawner = (delegate: ChildProcessSpawnerService["Service"]) => + ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.command === "docker" && + command.args[0] === "create" && + command.args.includes(`${DOCKER_HOST_ALIAS}:host-gateway`) + ? delegate.spawn( + ChildProcess.make(process.execPath, ["-e", hostGatewayRejectionScript], { + stdin: "ignore", + }), + ) + : delegate.spawn(command), + ); + +/** + * Replaces a host-gateway probe with the script `fake` returns for its attempt, if any; a probe + * spawns only once `release`, when given, completes. + */ +const makeHostGatewayProbeSpawner = ( + delegate: ChildProcessSpawnerService["Service"], + probes: Ref.Ref, + fake: (attempt: number) => string | undefined, + release?: Deferred.Deferred, +) => + ChildProcessSpawner.make((command) => { + if ( + !ChildProcess.isStandardCommand(command) || + command.command !== "docker" || + command.args[0] !== "run" || + !command.args.includes("/etc/hosts") + ) + return delegate.spawn(command); + return Effect.gen(function* () { + const script = fake(yield* Ref.updateAndGet(probes, (count) => count + 1)); + if (release !== undefined) yield* Deferred.await(release); + return yield* delegate.spawn( + script === undefined + ? command + : ChildProcess.make(process.execPath, ["-e", script], { stdin: "ignore" }), + ); + }); + }); + const makeStopFailureSpawner = ( delegate: ChildProcessSpawnerService["Service"], failStop: Ref.Ref, @@ -1326,6 +1753,20 @@ const inspectLabels = (id: string) => )(output.trim()); }); +const inspectExtraHosts = (id: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["inspect", "--format={{json .HostConfig.ExtraHosts}}", id], { + stdin: "ignore", + }), + ); + const output = yield* child.stdout.pipe(Stream.decodeText, Stream.mkString); + return yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.String)))( + output.trim(), + ); + }); + const removeExternally = (id: string) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 8217ad3461..43459fbdf9 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -2,6 +2,7 @@ import { Cause, Crypto, Data, + Deferred, type Duration, Effect, Exit, @@ -126,6 +127,100 @@ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''") const PULL_MAX_RETRIES = 4; +/** + * Host alias mapped in Docker containers' `/etc/hosts` to the engine's IPv4 host gateway, or to + * `host-gateway` for runtimes that do not await the probe; Docker Desktop's `host.docker.internal` + * and `host-gateway` also resolve to an IPv6 address. Engines that reject `host-gateway` get the + * IPv4 address they map to `host.docker.internal` or `host.containers.internal` instead. + */ +export const DOCKER_HOST_ALIAS = "host.supabase.internal"; + +/** Bounds the throwaway container that resolves the IPv4 host gateway. */ +const HOST_GATEWAY_PROBE_TIMEOUT: Duration.Input = "15 seconds"; + +const IPV4_ADDRESS = /^(?:\d{1,3}\.){3}\d{1,3}$/u; + +/** Names an engine may write into `/etc/hosts` for its host, such as Podman's compat socket. */ +const ENGINE_HOST_NAMES = ["host.docker.internal", "host.containers.internal"]; + +const firstIpv4For = (hosts: string, names: ReadonlyArray) => + hosts + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .find( + ([address, ...mapped]) => + IPV4_ADDRESS.test(address ?? "") && mapped.some((name) => names.includes(name)), + )?.[0]; + +/** Matches an engine that rejects the `host-gateway` keyword in `--add-host`, such as older Podman. */ +const rejectsHostGateway = (error: ContainerError) => + /(?:invalid|unknown|unsupported|bad)[^\n]*add-host[^\n]*host-gateway/iu.test(error.message); + +/** + * One stack host's `--add-host` target for `DOCKER_HOST_ALIAS`, shared by its Docker runtimes. + * At most one probe runs at a time, in the gateway's scope; a probe yielding `undefined` leaves + * the target unresolved so a later caller probes again, and a probe failure is cached. + */ +export interface HostGateway { + /** Awaits the cached or in-flight target, starting `probe` when there is neither. */ + readonly resolve: ( + probe: Effect.Effect, + ) => Effect.Effect; + /** Starts `probe` in the background unless a target is cached or in flight. */ + readonly prefetch: ( + probe: Effect.Effect, + ) => Effect.Effect; +} + +/** Probes on every platform: engines with IPv6 on the bridge map `host-gateway` to both families. */ +export const makeHostGateway: Effect.Effect = Effect.gen( + function* () { + const scope = yield* Scope.Scope; + const target = yield* Ref.make< + Deferred.Deferred | undefined + >(undefined); + const start = (probe: Effect.Effect) => + Effect.uninterruptible( + Effect.gen(function* () { + const fresh = yield* Deferred.make(); + const current = yield* Ref.modify(target, (state) => + state === undefined ? [undefined, fresh] : [state, state], + ); + if (current !== undefined) return current; + // Starting immediately installs `onExit` before a closed scope can interrupt the fiber. + yield* probe.pipe( + Effect.onExit((exit) => { + const failure = Exit.isFailure(exit) + ? Cause.findErrorOption(exit.cause) + : Option.none(); + if (Option.isSome(failure)) return Deferred.fail(fresh, failure.value); + const probed = Exit.isSuccess(exit) ? exit.value : undefined; + return (probed === undefined ? Ref.set(target, undefined) : Effect.void).pipe( + Effect.andThen(Deferred.succeed(fresh, probed)), + ); + }), + Effect.forkIn(scope, { startImmediately: true }), + ); + return fresh; + }), + ); + return { + // A waiter whose shared probe failed retries once before falling back. + resolve: (probe) => + start(probe).pipe( + Effect.flatMap(Deferred.await), + Effect.flatMap((probed) => + probed === undefined + ? start(probe).pipe(Effect.flatMap(Deferred.await)) + : Effect.succeed(probed), + ), + Effect.map((probed) => probed ?? "host-gateway"), + ), + prefetch: (probe) => Effect.asVoid(start(probe)), + }; + }, +); + const pullBackoff = Schedule.exponential("2 seconds").pipe(Schedule.jittered); /** `docker create` only writes metadata; a healthy daemon answers well within this bound. */ @@ -159,10 +254,21 @@ export const makeContainerRuntime = (options: { readonly engine: "docker" | "podman"; readonly root: string; readonly imageMirrors?: (image: string) => ReadonlyArray; + /** Omitted gives this runtime its own host-gateway probe. */ + readonly hostGateway?: HostGateway; + /** + * `false` launches with `host-gateway` at once and resolves the IPv4 target in the background, + * for containers that do not rely on reaching the host over IPv4. + */ + readonly awaitHostGateway?: boolean; }): Effect.Effect< ContainerRuntime, never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | Scope.Scope > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -280,6 +386,71 @@ export const makeContainerRuntime = (options: { prepareImage(image).pipe(Effect.asVoid), ); + const hostGateway = options.hostGateway ?? (yield* makeHostGateway); + /** Reads `/etc/hosts` from a throwaway container of an already present image. */ + const readProbeHosts = (image: string, spec: ContainerSpec, addHost: ReadonlyArray) => + run( + [ + "run", + "--rm", + "--pull", + "never", + ...addHost, + // No instance label: `--rm` removal is asynchronous and must not count as an + // instance container; the stack labels keep it sweepable. + "--label", + `com.supabase.stack=${spec.stackId}`, + "--label", + `com.supabase.stack-root=${stackRoot}`, + "--entrypoint", + "cat", + image, + "/etc/hosts", + ], + { timeout: undefined }, + ).pipe(Effect.timeout(HOST_GATEWAY_PROBE_TIMEOUT)); + /** Resolves the IPv4 host address the engine writes itself, since it rejects `host-gateway`. */ + const engineHostProbe = (image: string, spec: ContainerSpec, rejection: ContainerError) => + readProbeHosts(image, spec, []).pipe( + Effect.matchEffect({ + // A failed or slow read says nothing about the engine, so the next launch retries. + onFailure: (error) => + Effect.logDebug(`Engine host probe failed: ${error.message}`).pipe( + Effect.as(undefined), + ), + onSuccess: (hosts) => { + const address = firstIpv4For(hosts, ENGINE_HOST_NAMES); + return address === undefined + ? Effect.fail( + new ContainerError({ + operation: "host-gateway", + message: `The container engine rejects host-gateway in --add-host and maps no IPv4 address to ${ENGINE_HOST_NAMES.join(" or ")}; upgrade Podman or use --runtime podman`, + cause: rejection, + }), + ) + : Effect.succeed(address); + }, + }), + ); + /** Probes the engine's first IPv4 `host-gateway` address with an already present image. */ + const hostGatewayProbe = (image: string, spec: ContainerSpec) => + readProbeHosts(image, spec, ["--add-host", `${DOCKER_HOST_ALIAS}:host-gateway`]).pipe( + // No IPv4 entry is a stable engine answer, so `host-gateway` is cached rather than re-probed. + Effect.map((hosts) => firstIpv4For(hosts, [DOCKER_HOST_ALIAS]) ?? "host-gateway"), + // A failed or slow probe (e.g. an image without `cat`) is retried by the next launch. + Effect.catch((error) => + error instanceof ContainerError && rejectsHostGateway(error) + ? engineHostProbe(image, spec, error) + : Effect.logDebug(`Host gateway probe failed: ${error.message}`).pipe( + Effect.as(undefined), + ), + ), + ); + const hostAliasTarget = (image: string, spec: ContainerSpec) => + options.awaitHostGateway === false + ? hostGateway.prefetch(hostGatewayProbe(image, spec)).pipe(Effect.as("host-gateway")) + : hostGateway.resolve(hostGatewayProbe(image, spec)); + const launch = Effect.fn("Container.launch")(function* ( spec: ContainerSpec, interactive = false, @@ -316,13 +487,21 @@ export const makeContainerRuntime = (options: { Effect.mapError((cause) => errorFor("identity", cause)), ); const { name, composeProject, composeService } = identifyContainer(spec, token, oneOff); - const args = [ + const hostAlias = + options.engine === "docker" ? yield* hostAliasTarget(image, spec) : undefined; + const createArgs = (target: string | undefined) => [ "create", "--pull", "never", ...(interactive ? ["--interactive", "--init"] : []), - ...(options.engine === "docker" && process.platform === "linux" - ? ["--add-host", "host.docker.internal:host-gateway"] + ...(target !== undefined + ? [ + "--add-host", + `${DOCKER_HOST_ALIAS}:${target}`, + ...(process.platform === "linux" + ? ["--add-host", `host.docker.internal:${target}`] + : []), + ] : []), "--name", name, @@ -359,6 +538,16 @@ export const makeContainerRuntime = (options: { image, ...(spec.args ?? []), ]; + const create = run(createArgs(hostAlias), { timeout: undefined }).pipe( + // An unawaited `host-gateway` can reach an engine that rejects it before the probe answers. + Effect.catchTag("ContainerError", (error) => + hostAlias === "host-gateway" && rejectsHostGateway(error) + ? hostGateway + .resolve(hostGatewayProbe(image, spec)) + .pipe(Effect.flatMap((target) => run(createArgs(target), { timeout: undefined }))) + : Effect.fail(error), + ), + ); return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -367,7 +556,7 @@ export const makeContainerRuntime = (options: { // call; either that timeout or an external interrupt reaching this window may still // leave a container needing best-effort removal, handled in both branches below. const creation = yield* restore( - run(args, { timeout: undefined }).pipe( + create.pipe( Effect.timeout(CREATE_TIMEOUT), Effect.mapError((error) => error._tag === "TimeoutError" diff --git a/packages/stack/src/runtime/Container.unit.test.ts b/packages/stack/src/runtime/Container.unit.test.ts new file mode 100644 index 0000000000..fbd132bb2e --- /dev/null +++ b/packages/stack/src/runtime/Container.unit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"; +import { ContainerError, makeHostGateway } from "./Container.ts"; + +const gatewayAddress = "192.168.65.254"; + +const gatedProbe = Effect.fnUntraced(function* (result: string | undefined) { + const release = yield* Deferred.make(); + const runs = yield* Ref.make(0); + const probe = Ref.update(runs, (count) => count + 1).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(result), + ); + return { probe, runs, release: Deferred.succeed(release, undefined) }; +}); + +describe("host gateway", () => { + it.effect("prefetches without waiting and lets a later resolve await the same probe", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const { probe, runs, release } = yield* gatedProbe(gatewayAddress); + + yield* gateway.prefetch(probe); + const waiting = yield* gateway.resolve(probe).pipe(Effect.forkChild); + yield* release; + + expect(yield* Fiber.join(waiting)).toBe(gatewayAddress); + expect(yield* gateway.resolve(probe)).toBe(gatewayAddress); + expect(yield* Ref.get(runs)).toBe(1); + }), + ); + + it.effect("retries once for a waiter whose background probe finds no target", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const background = yield* gatedProbe(undefined); + const later = yield* gatedProbe(gatewayAddress); + + yield* gateway.prefetch(background.probe); + const waiting = yield* gateway + .resolve(later.probe) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* background.release; + yield* later.release; + + expect(yield* Fiber.join(waiting)).toBe(gatewayAddress); + expect(yield* Ref.get(later.runs)).toBe(1); + expect(yield* gateway.resolve(later.probe)).toBe(gatewayAddress); + expect(yield* Ref.get(later.runs)).toBe(1); + }), + ); + + it.effect("caches a probe failure for later resolves without probing again", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const runs = yield* Ref.make(0); + const unsupported = new ContainerError({ operation: "host-gateway", message: "unsupported" }); + const probe = Ref.update(runs, (count) => count + 1).pipe( + Effect.andThen(Effect.fail(unsupported)), + ); + + expect(yield* Effect.flip(gateway.resolve(probe))).toBe(unsupported); + expect(yield* Effect.flip(gateway.resolve(probe))).toBe(unsupported); + expect(yield* Ref.get(runs)).toBe(1); + }), + ); + + it.effect("keeps the probe running when a waiting resolve is interrupted", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const { probe, runs, release } = yield* gatedProbe(gatewayAddress); + + const waiting = yield* gateway + .resolve(probe) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(waiting); + yield* release; + + expect(yield* gateway.resolve(probe)).toBe(gatewayAddress); + expect(yield* Ref.get(runs)).toBe(1); + }), + ); + + it.effect("interrupts its probe when the owning scope closes", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const gateway = yield* makeHostGateway.pipe(Scope.provide(scope)); + const interrupted = yield* Deferred.make(); + + yield* gateway.prefetch( + Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + ); + yield* Scope.close(scope, Exit.void); + + expect(yield* Deferred.isDone(interrupted)).toBe(true); + expect(yield* gateway.resolve(Effect.never)).toBe("host-gateway"); + }), + ); +}); diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index 31a4a7ad2e..64e7828df5 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -328,6 +328,7 @@ export const makeServiceRecipe = Effect.fn("Catalog.makeServiceRecipe")( cacheRoot: options.cacheRoot, runtime: options.runtime, ...(options.helpers === undefined ? {} : { helpers: options.helpers }), + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }).pipe( Effect.mapError( (cause) => @@ -348,6 +349,7 @@ export const makeServiceRecipe = Effect.fn("Catalog.makeServiceRecipe")( engine: options.runtime, root: options.root, imageMirrors: slimImageMirrors, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }); const deps: ProcessDependencies = { fs, path, crypto, client, spawner, container }; switch (creation.service) { diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 943cd9958e..f88c06fa7c 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -31,6 +31,7 @@ import { makeContainerRuntime, type ContainerProcess, type ContainerRuntime, + type HostGateway, } from "../runtime/Container.ts"; import { DatabaseBootstrapError, runDatabaseBootstrap } from "../runtime/DatabaseBootstrap.ts"; import { @@ -133,6 +134,8 @@ export interface DatabaseOptions { readonly runtime: DatabaseRuntime; /** Reuses one volume helper across databases in this host. */ readonly helpers?: DockerHelperRegistry; + /** Shares one host-gateway probe across this host's container runtimes. */ + readonly hostGateway?: HostGateway; } export interface DatabaseComponent { @@ -513,6 +516,9 @@ export const makeDatabase = ( engine: options.runtime, root: options.root, imageMirrors: slimImageMirrors, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), + // PostgreSQL's outbound HTTP falls back across address families. + awaitHostGateway: false, }); const storage: DockerDatabaseStorage | undefined = options.runtime === "native" diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 053c069f2d..12a6ac4d60 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -1,10 +1,25 @@ -import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { NodeHttpClient, NodePath, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, FileSystem, Layer, Ref, Schema, Stream } from "effect"; +import { + Cause, + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + Path, + Ref, + Schema, + Scope, + Stream, +} from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ContainerError, type ContainerRuntime } from "../runtime/Container.ts"; import { makeService } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; +import * as Functions from "./Functions.ts"; const options = (root: string) => ({ stackId: "catalog-functions", @@ -485,3 +500,85 @@ for (const runtime of ["native", "docker"] as const) { { timeout: 120_000 }, ); } + +it.effect("passes POSIX project paths to a docker Functions container from a Windows host", () => + Effect.scoped( + Effect.gen(function* () { + const launched = yield* Ref.make[0] | undefined>( + undefined, + ); + const container: ContainerRuntime = { + prepare: () => Effect.void, + prepareImage: (image) => Effect.succeed(image), + launchCommand: () => Effect.die("Functions has no startup commands"), + launch: (spec) => + Ref.set(launched, spec).pipe( + Effect.andThen( + Effect.fail(new ContainerError({ operation: "start", message: "captured" })), + ), + ), + }; + const filesRoot = "C:\\Users\\dev\\project"; + const creation: Functions.Creation = { + service: "functions", + config: { + functionsRoot: `${filesRoot}\\supabase\\functions`, + filesRoot, + functions: { + hello: { + entrypoint: `${filesRoot}\\source\\main.ts`, + import_map: `${filesRoot}\\supabase\\import_map.json`, + static_files: [`${filesRoot}\\source\\*.txt`], + }, + }, + }, + }; + const recipe = yield* Functions.makeRecipe( + creation, + { + stackId: "e".repeat(64), + instanceId: "windows", + root: "C:\\Users\\dev\\stack", + cacheRoot: "C:\\Users\\dev\\cache", + runtime: "docker", + }, + { + fs: yield* FileSystem.FileSystem, + path: yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)), + crypto: yield* Crypto.Crypto, + client: yield* HttpClient.HttpClient, + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + container, + }, + ); + const scope = yield* Scope.make(); + yield* recipe.definition + .launch({ id: "windows", config: creation, scope }) + .pipe(Effect.flip, Effect.ensuring(Scope.close(scope, Exit.void))); + + const spec = yield* Ref.get(launched); + expect(spec?.env.SUPABASE_INTERNAL_FUNCTIONS_ROOT).toBe( + "/__supabase_project/supabase/functions", + ); + expect(spec?.env.SUPABASE_INTERNAL_FUNCTIONS_FILES_ROOT).toBe("/__supabase_project"); + expect(spec?.args).toContain("--main-service=/__supabase_functions"); + const config = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + entrypoint: Schema.optionalKey(Schema.String), + import_map: Schema.optionalKey(Schema.String), + static_files: Schema.optionalKey(Schema.Array(Schema.String)), + }), + ), + ), + )(spec?.env.SUPABASE_INTERNAL_FUNCTIONS_CONFIG); + expect(config.hello).toEqual({ + entrypoint: "/__supabase_project/source/main.ts", + import_map: "/__supabase_project/supabase/import_map.json", + static_files: ["/__supabase_project/source/*.txt"], + }); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/services/Functions.ts b/packages/stack/src/services/Functions.ts index 4aca150ef9..d3c81eb740 100644 --- a/packages/stack/src/services/Functions.ts +++ b/packages/stack/src/services/Functions.ts @@ -91,13 +91,17 @@ const makeSpec = ( }), ), ); - const runtimePath = (value: string) => - !path.isAbsolute(value) || filesRoot === undefined || canonicalFilesRoot === undefined - ? value - : path.join( - container ? "/__supabase_project" : canonicalFilesRoot, - path.relative(filesRoot, value), - ); + const runtimePath = (value: string) => { + if (!path.isAbsolute(value) || filesRoot === undefined || canonicalFilesRoot === undefined) + return value; + const relative = path.relative(filesRoot, value); + // Container paths are POSIX regardless of the host path flavor. + return container + ? ["/__supabase_project", ...relative.split(path.sep).filter((part) => part !== "")].join( + "/", + ) + : path.join(canonicalFilesRoot, relative); + }; const root = container && filesRoot === undefined ? "/__supabase_functions" diff --git a/packages/stack/src/services/Realtime.ts b/packages/stack/src/services/Realtime.ts index fdea63c338..87a7a46f35 100644 --- a/packages/stack/src/services/Realtime.ts +++ b/packages/stack/src/services/Realtime.ts @@ -70,8 +70,8 @@ export const makeSpec = (): ProcessRecipeSpec => ({ MAX_HEADER_LENGTH: String(creation.config.maxHeaderLength ?? 4096), ERL_AFLAGS: creation.config.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", - // The stack database is reachable only over IPv4; unset, Realtime prefers IPv6 for - // dual-stack hosts such as Docker Desktop's host.docker.internal. + // The stack database listens on IPv4 only. This covers Realtime's own repo; tenant + // connections probe IPv6 first and rely on the runtime host alias having no AAAA record. DB_IP_VERSION: "ipv4", RUN_JANITOR: "true", ...(rpc === undefined diff --git a/packages/stack/src/services/Recipe.ts b/packages/stack/src/services/Recipe.ts index b28f36b438..940e1f5f38 100644 --- a/packages/stack/src/services/Recipe.ts +++ b/packages/stack/src/services/Recipe.ts @@ -3,6 +3,7 @@ import type { Stream } from "effect"; import type { Effect, Ref } from "effect"; import type { ServiceKind } from "../Artifacts.ts"; import type { ServiceDefinition } from "../Service.ts"; +import type { HostGateway } from "../runtime/Container.ts"; import type { DockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; type CatalogRuntime = "native" | "docker" | "podman"; @@ -53,6 +54,8 @@ export interface CatalogOptions { readonly platform?: { readonly os: string; readonly arch: string }; /** Reuses one volume helper across databases in this host. */ readonly helpers?: DockerHelperRegistry; + /** Shares one host-gateway probe across this host's container runtimes. */ + readonly hostGateway?: HostGateway; } export interface CatalogLog { From 48cabe354c16ce4c67199c4b46bbdca4265d17ae Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 12:50:34 +0000 Subject: [PATCH 54/71] feat(cli): graduate OrioleDB configuration from experimental (#6828) Graduate OrioleDB local setup out of experimental for the OrioleDB public beta. - `supabase init --use-orioledb` no longer requires `--experimental`. - The OrioleDB version setting moves from `experimental.orioledb_version` to `db.orioledb_version`, and its environment override is now `SUPABASE_DB_ORIOLEDB_VERSION`. - Existing configs that set `[experimental] orioledb_version` keep working: the value is used when `db.orioledb_version` is unset or empty, and the CLI prints a deprecation warning. - `supabase init --use-orioledb` now writes the current OrioleDB Postgres 17 release (`17.11.0.002`) instead of a Postgres 15 alpha version that did not match the default `db.major_version = 17`. - `@supabase/config` adds `db.orioledb_version` and marks `experimental.orioledb_version` as deprecated. Companion docs PR: https://github.com/supabase/supabase/pull/50908. --- apps/cli-go/internal/db/start/start.go | 2 +- apps/cli-go/internal/utils/config.go | 2 +- apps/cli-go/internal/utils/config_test.go | 2 +- apps/cli-go/pkg/config/config.go | 77 ++++++- apps/cli-go/pkg/config/config_test.go | 105 ++++++++++ apps/cli-go/pkg/config/db.go | 1 + apps/cli-go/pkg/config/templates/config.toml | 4 +- apps/cli-go/pkg/config/testdata/config.toml | 3 +- apps/cli/docs/supabase/init.md | 2 + apps/cli/docs/templates/examples.yaml | 4 + .../db-bootstrap/bootstrap-config.ts | 10 +- .../db-bootstrap/local-container-inputs.ts | 2 +- .../db-bootstrap/postgres.service.ts | 13 +- .../postgres.service.unit.test.ts | 4 +- .../shadow-cache.integration.test.ts | 2 +- .../db-bootstrap/shadow-cache.ts | 2 +- .../db-bootstrap/shadow-database.ts | 1 + .../command-internal/db-config.toml-read.ts | 16 +- .../db-config.toml-read.unit.test.ts | 142 ++++++++++++- apps/cli/src/command-internal/db-image.ts | 4 +- apps/cli/src/command-internal/stack-config.ts | 12 +- .../cli/src/commands/db/start/SIDE_EFFECTS.md | 2 +- ...ack-config-environment.integration.test.ts | 11 +- .../stack/stack-config.integration.test.ts | 13 +- apps/cli/src/commands/init/SIDE_EFFECTS.md | 9 +- apps/cli/src/commands/init/init.errors.ts | 15 -- apps/cli/src/commands/init/init.handler.ts | 11 +- .../commands/init/init.integration.test.ts | 26 +-- apps/cli/src/commands/start/start.handler.ts | 8 +- .../commands/start/start.integration.test.ts | 6 +- .../src/shared/init/project-init.templates.ts | 6 +- .../init/project-init.templates.unit.test.ts | 2 +- .../init/testdata/go-templates/config.toml | 4 +- .../telemetry/__fixtures__/error-tags.txt | 1 - apps/docs/public/cli/config.schema.json | 12 +- packages/config/src/db.ts | 7 + .../config/src/entrypoint-purity.unit.test.ts | 1 + packages/config/src/experimental.ts | 3 +- packages/config/src/internal.ts | 1 + packages/config/src/io.ts | 108 +++++++++- packages/config/src/io.unit.test.ts | 197 ++++++++++++++++++ .../src/project-config/hosted-sections.ts | 5 +- .../project-config.unit.test.ts | 1 + 43 files changed, 733 insertions(+), 126 deletions(-) diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go index dc8327c816..d4c97ba1d5 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -66,7 +66,7 @@ func NewContainerConfig(args ...string) container.Config { "JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, fmt.Sprintf("JWT_EXP=%d", utils.Config.Auth.JwtExpiry), } - if len(utils.Config.Experimental.OrioleDBVersion) > 0 { + if len(utils.Config.Db.OrioleDBVersion) > 0 { env = append(env, "POSTGRES_INITDB_ARGS=--lc-collate=C --lc-ctype=C", fmt.Sprintf("S3_ENABLED=%t", true), diff --git a/apps/cli-go/internal/utils/config.go b/apps/cli-go/internal/utils/config.go index d171d06753..2ec13b26aa 100644 --- a/apps/cli-go/internal/utils/config.go +++ b/apps/cli-go/internal/utils/config.go @@ -224,7 +224,7 @@ func InitConfig(params InitParams, fsys afero.Fs) error { c := config.NewConfig() c.ProjectId = params.ProjectId if params.UseOrioleDB { - c.Experimental.OrioleDBVersion = "15.1.0.150" + c.Db.OrioleDBVersion = "17.11.0.002" } // The supabase init command opts new projects into pg-delta. Existing configs are // unaffected because mergeDefaultValues ejects with this flag false (default stays diff --git a/apps/cli-go/internal/utils/config_test.go b/apps/cli-go/internal/utils/config_test.go index 6d829304f1..98cd4da9e8 100644 --- a/apps/cli-go/internal/utils/config_test.go +++ b/apps/cli-go/internal/utils/config_test.go @@ -113,7 +113,7 @@ func TestInitConfig(t *testing.T) { assert.NoError(t, err) content, err := afero.ReadFile(fsys, ConfigPath) assert.NoError(t, err) - assert.Contains(t, string(content), "15.1.0.150") + assert.Contains(t, string(content), "17.11.0.002") }) t.Run("fails if config exists and no overwrite", func(t *testing.T) { diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 01c426edd8..0491495db9 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -598,6 +598,7 @@ func (c *config) loadFromFile(filename string, fsys fs.FS) error { return err } v = normalizeDeprecatedSMTPConfig(v, fileConfig) + v = normalizeDeprecatedOrioleDBConfig(v, fileConfig) // Find [remotes.*] block to override base config idToName := map[string]string{} for name, remote := range v.GetStringMap("remotes") { @@ -644,10 +645,14 @@ func normalizeDeprecatedSMTPConfig(v, fileConfig *viper.Viper) *viper.Viper { if !changed { return v } - // Rebuild the viper from the rewritten settings so the now-removed - // `inbucket` key does not trip UnmarshalExact. Preserve the env-binding - // options from the original instance, otherwise SUPABASE_-prefixed env - // overrides bound via ExperimentalBindStruct would be silently dropped. + return rebuildViperFromSettings(settings, v) +} + +// rebuildViperFromSettings rebuilds a viper instance from rewritten settings so a +// now-removed deprecated key does not trip UnmarshalExact. It preserves the +// env-binding options from the original instance, otherwise SUPABASE_-prefixed +// env overrides bound via ExperimentalBindStruct would be silently dropped. +func rebuildViperFromSettings(settings map[string]any, original *viper.Viper) *viper.Viper { u := viper.NewWithOptions( viper.ExperimentalBindStruct(), viper.EnvKeyReplacer(strings.NewReplacer(".", "_")), @@ -655,11 +660,65 @@ func normalizeDeprecatedSMTPConfig(v, fileConfig *viper.Viper) *viper.Viper { u.SetEnvPrefix("SUPABASE") u.AutomaticEnv() if err := u.MergeConfigMap(settings); err != nil { - return v + return original } return u } +func normalizeDeprecatedOrioleDBConfig(v, fileConfig *viper.Viper) *viper.Viper { + settings := v.AllSettings() + changed := promoteDeprecatedOrioleDBVersion(settings, fileConfig, "", "experimental.orioledb_version", "db.orioledb_version") + if remotes, ok := settings["remotes"].(map[string]any); ok { + for name, raw := range remotes { + remote, ok := raw.(map[string]any) + if !ok { + continue + } + legacyKey := fmt.Sprintf("remotes.%s.experimental.orioledb_version", name) + dbKey := fmt.Sprintf("remotes.%s.db.orioledb_version", name) + if promoteDeprecatedOrioleDBVersion(remote, fileConfig, name, legacyKey, dbKey) { + changed = true + } + } + } + if !changed { + return v + } + return rebuildViperFromSettings(settings, v) +} + +// promoteDeprecatedOrioleDBVersion promotes a non-empty legacy `experimental.orioledb_version` +// onto `db.orioledb_version` in settings when the latter is absent or empty in the user's +// file (the template always writes `db.orioledb_version = ""`, so an unset value is +// indistinguishable from an explicit empty string), and warns on stderr whenever the legacy +// value is non-empty. remoteName is empty for the top-level config, or a `[remotes.*]` name +// for the warning message. +func promoteDeprecatedOrioleDBVersion(settings map[string]any, fileConfig *viper.Viper, remoteName, legacyKey, dbKey string) bool { + legacyVal := fileConfig.GetString(legacyKey) + if legacyVal == "" { + return false + } + if fileConfig.GetString(dbKey) == "" { + db, ok := settings["db"].(map[string]any) + if !ok { + db = map[string]any{} + settings["db"] = db + } + db["orioledb_version"] = legacyVal + } + if remoteName == "" { + fmt.Fprintln(os.Stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + } else { + fmt.Fprintf( + os.Stderr, + "WARN: remotes.%s.experimental.orioledb_version is deprecated. Please use remotes.%s.db.orioledb_version instead.\n", + remoteName, + remoteName, + ) + } + return true +} + // renameDeprecatedSMTP removes the deprecated `inbucket` key from settings. When // promote is true (no explicit `local_smtp` is present), the inbucket values are // deep-merged over any existing `local_smtp` defaults so a partial `[inbucket]` @@ -1034,11 +1093,11 @@ func (c *config) Validate(fsys fs.FS) error { return errors.New("Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.") case 13, 14: case 15, 17: - if len(c.Experimental.OrioleDBVersion) > 0 { - if VersionCompare(c.Experimental.OrioleDBVersion, "15.1.1.13") > 0 { - c.Db.Image = fmt.Sprintf("supabase/postgres:%s-orioledb", c.Experimental.OrioleDBVersion) + if len(c.Db.OrioleDBVersion) > 0 { + if VersionCompare(c.Db.OrioleDBVersion, "15.1.1.13") > 0 { + c.Db.Image = fmt.Sprintf("supabase/postgres:%s-orioledb", c.Db.OrioleDBVersion) } else { - c.Db.Image = "supabase/postgres:orioledb-" + c.Experimental.OrioleDBVersion + c.Db.Image = "supabase/postgres:orioledb-" + c.Db.OrioleDBVersion } if err := assertEnvLoaded(c.Experimental.S3Host); err != nil { return err diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index c3c3f871da..0f51bf5153 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -4,6 +4,7 @@ import ( "bytes" _ "embed" "fmt" + "io" "os" "path" "strings" @@ -1158,3 +1159,107 @@ port = 12345 assert.Equal(t, uint16(12345), config.Inbucket.Port) }) } + +func TestDeprecatedOrioleDBVersionConfig(t *testing.T) { + captureStderr := func(t *testing.T, run func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + defer w.Close() + orig := os.Stderr + os.Stderr = w + defer func() { os.Stderr = orig }() + run() + require.NoError(t, w.Close()) + var out bytes.Buffer + _, err = io.Copy(&out, r) + require.NoError(t, err) + return out.String() + } + + t.Run("promotes deprecated [experimental] orioledb_version to [db]", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "15.1.0.150" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "15.1.0.150", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + }) + + t.Run("does not warn when [experimental] orioledb_version is empty", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "", config.Db.OrioleDBVersion) + assert.NotContains(t, stderr, "orioledb_version is deprecated") + }) + + t.Run("promotes deprecated [experimental] orioledb_version when [db] is explicitly empty", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "x" + +[db] +orioledb_version = "" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "x", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + }) + + t.Run("prefers explicit [db] orioledb_version over deprecated [experimental]", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "15.1.0.150" + +[db] +orioledb_version = "15.1.1.13" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "15.1.1.13", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + }) + + t.Run("normalizes deprecated [remotes.*.experimental] orioledb_version", func(t *testing.T) { + config := NewConfig() + config.ProjectId = "abcdefghijklmnopqrst" + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.experimental] +orioledb_version = "15.1.0.150" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "15.1.0.150", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: remotes.staging.experimental.orioledb_version is deprecated. Please use remotes.staging.db.orioledb_version instead.") + }) +} diff --git a/apps/cli-go/pkg/config/db.go b/apps/cli-go/pkg/config/db.go index 1f2b3d1ded..821c0a554b 100644 --- a/apps/cli-go/pkg/config/db.go +++ b/apps/cli-go/pkg/config/db.go @@ -85,6 +85,7 @@ type ( ShadowPort uint16 `toml:"shadow_port" json:"shadow_port"` HealthTimeout time.Duration `toml:"health_timeout" json:"health_timeout"` MajorVersion uint `toml:"major_version" json:"major_version"` + OrioleDBVersion string `toml:"orioledb_version" json:"orioledb_version"` Password string `toml:"-" json:"-"` RootKey Secret `toml:"root_key" json:"root_key"` Pooler pooler `toml:"pooler" json:"pooler"` diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index 07bacc0ade..c26043d5c9 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -40,6 +40,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. Leave empty to use the default engine. +orioledb_version = "{{ .Db.OrioleDBVersion }}" [db.pooler] enabled = false @@ -393,8 +395,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "{{ .Experimental.OrioleDBVersion }}" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "env(S3_HOST)" # Configures S3 bucket region, eg. us-east-1 diff --git a/apps/cli-go/pkg/config/testdata/config.toml b/apps/cli-go/pkg/config/testdata/config.toml index 1c60b8af17..4c6ffc0c80 100644 --- a/apps/cli-go/pkg/config/testdata/config.toml +++ b/apps/cli-go/pkg/config/testdata/config.toml @@ -34,6 +34,7 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +orioledb_version = "15.1.0.150" [db.migrations] # If disabled, migrations will be skipped during a db push or reset. @@ -347,8 +348,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "15.1.0.150" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "orioledb.s3-accelerate.amazonaws.com" # Configures S3 bucket region, eg. us-east-1 diff --git a/apps/cli/docs/supabase/init.md b/apps/cli/docs/supabase/init.md index 1290561dba..a854093c12 100644 --- a/apps/cli/docs/supabase/init.md +++ b/apps/cli/docs/supabase/init.md @@ -7,3 +7,5 @@ A `supabase/config.toml` file is created in your current working directory. This > You may override the directory path by specifying the `SUPABASE_WORKDIR` environment variable or `--workdir` flag. In addition to `config.toml`, the `supabase` directory may also contain other Supabase objects, such as `migrations`, `functions`, `tests`, etc. + +To use OrioleDB as the Postgres storage engine, run `supabase init --use-orioledb`. The command writes the OrioleDB version to `db.orioledb_version` in `supabase/config.toml`; `--experimental` is not required. diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index bc451edb14..8d0e70222d 100644 --- a/apps/cli/docs/templates/examples.yaml +++ b/apps/cli/docs/templates/examples.yaml @@ -7,6 +7,10 @@ supabase-init: name: Initialize from an existing directory code: supabase init --workdir . response: Finished supabase init. + - id: with-orioledb + name: Initialize with OrioleDB + code: supabase init --use-orioledb + response: Finished supabase init. supabase-login: - id: basic-usage name: Basic usage diff --git a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts index 690bc0604d..577eb95193 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -113,13 +113,9 @@ export const resolveDbBootstrapConfig = ( // orioledb_version and the four S3 fields feed the Postgres container's image/env directly. // `envOverride` never throws, so these don't need `wrapConfigOverride`. Same remote-over-env // precedence as `majorVersion` applies to each. - const orioledbVersion = remoteWins("experimental.orioledb_version") - ? config.experimental.orioledb_version - : envOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - projectEnvValues, - ); + const orioledbVersion = remoteWins("db.orioledb_version") + ? config.db.orioledb_version + : envOverride("SUPABASE_DB_ORIOLEDB_VERSION", config.db.orioledb_version, projectEnvValues); const s3Host = remoteWins("experimental.s3_host") ? config.experimental.s3_host : envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", config.experimental.s3_host, projectEnvValues); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts index 5f5c2ac711..185b34966f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts @@ -156,6 +156,7 @@ export const buildLocalDbContainerInputs = ( ...config.db, port: values.dbPort, major_version: bootstrapConfig.majorVersion, + orioledb_version: bootstrapConfig.orioledbVersion, settings: resolveDbSettingsEnvOverrides( config.db.settings, projectEnvValues, @@ -164,7 +165,6 @@ export const buildLocalDbContainerInputs = ( }, experimental: { ...config.experimental, - orioledb_version: bootstrapConfig.orioledbVersion, s3_host: bootstrapConfig.s3Host, s3_region: bootstrapConfig.s3Region, s3_access_key: bootstrapConfig.s3AccessKey, diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts index e1343d75af..be97398051 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -64,9 +64,9 @@ const POSTGRES_INITDB_VERSION_THRESHOLD = "15.8.1.005"; const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; export interface PostgresStartServiceInput { - /** Decoded `[db]` section: `port`, `major_version`, and `settings`. */ + /** Decoded `[db]` section: `port`, `major_version`, `orioledb_version`, and `settings`. */ readonly db: CliConfig["db"]; - /** Decoded `[experimental]` section — only the OrioleDB/S3 fields are read. */ + /** Decoded `[experimental]` section — only the S3 fields are read. */ readonly experimental: CliConfig["experimental"]; /** Resolved `auth.jwt_secret`, as produced by `resolveLocalConfigValues`. */ readonly jwtSecret: string; @@ -170,10 +170,11 @@ export function postgresImageVersionTag(image: string): string { * for images older than {@link POSTGRES_INITDB_VERSION_THRESHOLD}. At most one branch fires. */ function postgresExtraEnv( + orioledbVersion: string | undefined, experimental: CliConfig["experimental"], image: string, ): Readonly> { - if (experimental.orioledb_version !== undefined && experimental.orioledb_version.length > 0) { + if (orioledbVersion !== undefined && orioledbVersion.length > 0) { return { POSTGRES_INITDB_ARGS: "--lc-collate=C --lc-ctype=C", S3_ENABLED: "true", @@ -285,7 +286,7 @@ export function buildPostgresStartContainerSpec( POSTGRES_HOST: "/var/run/postgresql", JWT_SECRET: input.jwtSecret, JWT_EXP: String(input.jwtExpiry), - ...postgresExtraEnv(input.experimental, input.configImage), + ...postgresExtraEnv(input.db.orioledb_version, input.experimental, input.configImage), }; const script = isRestore @@ -339,7 +340,7 @@ export const SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; * since the shadow container has no name and never restores from a backup) plus its own host port. */ export interface ShadowPostgresContainerSpecInput { - readonly db: Pick; + readonly db: Pick; readonly experimental: CliConfig["experimental"]; readonly jwtSecret: string; readonly jwtExpiry: number; @@ -377,7 +378,7 @@ export function buildShadowPostgresContainerSpec( POSTGRES_HOST: "/var/run/postgresql", JWT_SECRET: input.jwtSecret, JWT_EXP: String(input.jwtExpiry), - ...postgresExtraEnv(input.experimental, input.configImage), + ...postgresExtraEnv(input.db.orioledb_version, input.experimental, input.configImage), }; const script = isPg14OrEarlier diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts index 74ab5dd41b..f431f2181c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts @@ -138,8 +138,8 @@ describe("buildPostgresStartContainerSpec", () => { test("OrioleDB branch: adds POSTGRES_INITDB_ARGS + S3 env vars and skips the version-compare branch", () => { const spec = buildPostgresStartContainerSpec( baseInput({ + db: baseDb({ orioledb_version: "17.4.1.030" }), experimental: baseExperimental({ - orioledb_version: "17.4.1.030", s3_host: "s3.example.com", s3_region: "us-east-1", s3_access_key: "access-key", @@ -165,7 +165,7 @@ describe("buildPostgresStartContainerSpec", () => { test("OrioleDB branch defaults unset S3 fields to empty strings, matching Go's zero-value string fields", () => { const spec = buildPostgresStartContainerSpec( - baseInput({ experimental: baseExperimental({ orioledb_version: "17.4.1.030" }) }), + baseInput({ db: baseDb({ orioledb_version: "17.4.1.030" }) }), ); expect(spec.env).toMatchObject({ S3_HOST: "", diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 098313470a..de81930a4e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -307,7 +307,7 @@ describe("acquireShadowDatabase", () => { // tar is not a coherent snapshot — the acquire must degrade to the bare uncached shadow. const input = { ...shadowInput(fs, path), - experimental: { ...defaultConfig.experimental, orioledb_version: "15" }, + db: { major_version: 17, settings: {}, orioledb_version: "15" }, }; const handle = yield* acquireShadowDatabase(docker.spawner, input); expect(handle.baselinePresent).toBe(false); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 5caf37116c..e581d87702 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -257,7 +257,7 @@ const resolveShadowCacheKeyInputs = ( ): Effect.Effect, E> => Effect.gen(function* () { // OrioleDB keeps cluster state in S3, so a PGDATA tar is not a coherent snapshot. - const orioledbVersion = input.experimental.orioledb_version; + const orioledbVersion = input.db.orioledb_version; if (orioledbVersion !== undefined && orioledbVersion.length > 0) return Option.none(); // PG<=14 applies `ALTER ROLE … SET` on the setup session; a snapshot reconnect would diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index 41ad72b60e..717fbde5c4 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -358,6 +358,7 @@ export function shadowRunInputFromLocalContainerInputs( return { db: { major_version: postgresSpecBase.db.major_version, + orioledb_version: postgresSpecBase.db.orioledb_version, settings: postgresSpecBase.db.settings, }, experimental: postgresSpecBase.experimental, diff --git a/apps/cli/src/command-internal/db-config.toml-read.ts b/apps/cli/src/command-internal/db-config.toml-read.ts index de871c6fde..c92052d930 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -1,3 +1,4 @@ +import { normalizeDeprecatedOrioleDBVersion } from "@supabase/config/internal"; import { Config, Effect, Match, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; import { @@ -61,8 +62,9 @@ export interface DbTomlValues { /** `[db] major_version`, default 17. */ readonly majorVersion: number; /** - * `[experimental] orioledb_version` (env-expanded). Set on a 15/17 project to - * rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. + * `[db] orioledb_version` (env-expanded); the deprecated `[experimental] orioledb_version` is + * already promoted into this path by `normalizeDeprecatedOrioleDBVersion`. Set on a 15/17 + * project to rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. */ readonly orioledbVersion: Option.Option; /** @@ -286,7 +288,7 @@ const ENV_OVERRIDABLE_KEYS = [ "analytics.gcp_project_id", "analytics.gcp_project_number", "analytics.gcp_jwt_path", - "experimental.orioledb_version", + "db.orioledb_version", "experimental.s3_host", "experimental.s3_region", "experimental.s3_access_key", @@ -1113,6 +1115,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } + // Same per-section promotion as the config loader, before the remote merge; the loader owns + // the deprecation warning. + doc = asRecord(normalizeDeprecatedOrioleDBVersion(doc).document); // Config load aborts when two `[remotes.*]` blocks share a `project_id`, // regardless of which command runs — check before merging. const duplicateRemote = findDuplicateRemoteProjectId(doc, lookup); @@ -1274,7 +1279,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // checks the four S3 fields below; the image rewrite itself happens in `resolveDbImage`. const expandString = (value: unknown): Option.Option => typeof value === "string" ? nonEmptyString(expandEnv(value, lookup)) : Option.none(); - const orioledbVersion = expandString(experimentalRaw?.["orioledb_version"]); + const orioledbVersionRaw = + (remoteWins("db.orioledb_version") ? undefined : envOverride("SUPABASE_DB_ORIOLEDB_VERSION")) ?? + db?.["orioledb_version"]; + const orioledbVersion = expandString(orioledbVersionRaw); if (Option.isSome(orioledbVersion) && (majorVersion === 15 || majorVersion === 17)) { // Warns (does not fail) when an S3 field still holds an unexpanded `env(VAR)`; // matches the established stderr line, with the env var name from the capture. diff --git a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts index 105c2f31ac..ffa3a140d3 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts @@ -1926,14 +1926,14 @@ describe("readDbToml", () => { ); }); - it.effect("parses experimental.orioledb_version (env-expanded) on a 15/17 project", () => { + it.effect("parses db.orioledb_version (env-expanded) on a 15/17 project", () => { process.env["ORIOLE_VER"] = "16.0.0.1"; const dir = withConfig( [ "[db]", "major_version = 17", - "[experimental]", 'orioledb_version = "env(ORIOLE_VER)"', + "[experimental]", 's3_host = "s3.example.com"', 's3_region = "us-east-1"', 's3_access_key = "key"', @@ -1952,6 +1952,140 @@ describe("readDbToml", () => { ); }); + it.effect( + "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is absent", + () => { + const dir = withConfig( + [ + "[db]", + "major_version = 17", + "[experimental]", + 'orioledb_version = "15.1.0.150"', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is empty", + () => { + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = ""', + "[experimental]", + 'orioledb_version = "15.1.0.150"', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("prefers an explicit db.orioledb_version over a non-empty legacy value", () => { + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "17.0.0.1"', + "[experimental]", + 'orioledb_version = "15.1.0.150"', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("17.0.0.1"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "a matched remote's legacy experimental.orioledb_version overrides the base db.orioledb_version", + () => { + // Matches `@supabase/config`'s loader precedence: each `[remotes.*]` block's own legacy + // value is promoted before the remote merge, so it can override the base canonical value. + const ref = "abcdefghijklmnopqrst"; + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "a matched remote's legacy experimental.orioledb_version still beats a conflicting SUPABASE_DB_ORIOLEDB_VERSION", + () => { + // Same precedence as any other `ENV_OVERRIDABLE_KEYS` field (e.g. db.major_version): + // an explicit remote value beats its matching `SUPABASE_*` env override. + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_DB_ORIOLEDB_VERSION"]; + process.env["SUPABASE_DB_ORIOLEDB_VERSION"] = "env-value"; + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_ORIOLEDB_VERSION"]; + else process.env["SUPABASE_DB_ORIOLEDB_VERSION"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("warns (does not fail) for an unset S3 env on an OrioleDB project", () => { delete process.env["S3_KEY"]; const writes: Array = []; @@ -1964,8 +2098,8 @@ describe("readDbToml", () => { [ "[db]", "major_version = 15", - "[experimental]", 'orioledb_version = "15.1.0.55"', + "[experimental]", 's3_access_key = "env(S3_KEY)"', "", ].join("\n"), @@ -1999,8 +2133,8 @@ describe("readDbToml", () => { [ "[db]", "major_version = 15", - "[experimental]", 'orioledb_version = "15.1.0.55"', + "[experimental]", 's3_access_key = "env(S3_KEY_QUIET)"', "", ].join("\n"), diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index 1747f84ace..072b8b86b0 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -60,8 +60,8 @@ export const resolveDbImage = Effect.fnUntraced(function* ( majorVersion: number, orioledbVersion?: string, ) { - // OrioleDB override: on a 15/17 project with `experimental.orioledb_version` set, the Postgres - // image is replaced with the OrioleDB tag, taking precedence over the default/pinned image. + // OrioleDB override: on a 15/17 project with `db.orioledb_version` set, the Postgres image is + // replaced with the OrioleDB tag, taking precedence over the default/pinned image. if ( orioledbVersion !== undefined && orioledbVersion.length > 0 && diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index c17ef8e208..cb2c35adb3 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -375,14 +375,7 @@ const resolveEffectiveCliConfig = ( const mail = config.local_smtp; const pooler = db.pooler; const edge = config.edge_runtime; - const experimental = { - ...config.experimental, - orioledb_version: envOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - env, - ), - }; + const experimental = config.experimental; const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, env); const apiExtraSearchPathOverride = envOverride("SUPABASE_API_EXTRA_SEARCH_PATH", undefined, env); const imageDocument = section(section(document, "storage"), "image_transformation"); @@ -666,6 +659,7 @@ const resolveEffectiveCliConfig = ( port: resolvedPort("SUPABASE_DB_PORT", db.port, "db.port", env), major_version: envOverrideMajorVersion(db.major_version, env), health_timeout: envOverride("SUPABASE_DB_HEALTH_TIMEOUT", db.health_timeout, env), + orioledb_version: envOverride("SUPABASE_DB_ORIOLEDB_VERSION", db.orioledb_version, env), settings: resolveDbSettingsEnvOverrides(db.settings, env), pooler: resolvedPooler, }, @@ -731,7 +725,7 @@ const unsupportedConfigPaths = [ { path: "analytics.gcp_jwt_path", active: (config: CliConfig) => config.analytics.enabled }, { path: "edge_runtime.deno_version", active: (config: CliConfig) => config.edge_runtime.enabled }, { path: "storage.analytics", active: (config: CliConfig) => config.storage.enabled }, - { path: "experimental.orioledb_version", active: (_config: CliConfig) => true }, + { path: "db.orioledb_version", active: (_config: CliConfig) => true }, ] as const; const pathValue = (value: unknown, path: string): unknown => { diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md index dacc88ac69..05ade20e61 100644 --- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md @@ -130,7 +130,7 @@ API request over the active context's local unix socket / named pipe: | `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | | `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | | `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_DB_ORIOLEDB_VERSION` | overrides `db.orioledb_version` (image + env) | no | | `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | | `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | | `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | diff --git a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts index ed5108a55e..45a41ae98b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts @@ -137,14 +137,11 @@ enabled = false it.live("rejects the existing OrioleDB environment override", () => Effect.gen(function* () { const root = yield* project('project_id = "stack-config-env-orioledb"\n'); - const exit = yield* withEnvVar( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - "15.1.1.14", - load(root), - ).pipe(Effect.exit); + const exit = yield* withEnvVar("SUPABASE_DB_ORIOLEDB_VERSION", "15.1.1.14", load(root)).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) - expect(String(exit.cause)).toContain("experimental.orioledb_version"); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("db.orioledb_version"); }), ); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index ccbf300274..e32f41ad53 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -348,7 +348,18 @@ orioledb_version = "15.1.1.14" const orioledbExit = yield* load(orioledb).pipe(Effect.exit); expect(Exit.isFailure(orioledbExit)).toBe(true); if (Exit.isFailure(orioledbExit)) - expect(String(orioledbExit.cause)).toContain("experimental.orioledb_version"); + expect(String(orioledbExit.cause)).toContain("db.orioledb_version"); + + // The same rejection applies to the canonical `[db]` location, not just the deprecated + // `[experimental]` alias. + const orioledbCanonical = yield* project(`project_id = "stack-config-orioledb-db" +[db] +orioledb_version = "15.1.1.14" +`); + const orioledbCanonicalExit = yield* load(orioledbCanonical).pipe(Effect.exit); + expect(Exit.isFailure(orioledbCanonicalExit)).toBe(true); + if (Exit.isFailure(orioledbCanonicalExit)) + expect(String(orioledbCanonicalExit.cause)).toContain("db.orioledb_version"); const s3 = yield* project(`project_id = "stack-config-experimental-s3" [experimental] diff --git a/apps/cli/src/commands/init/SIDE_EFFECTS.md b/apps/cli/src/commands/init/SIDE_EFFECTS.md index c9cdca403a..d42d518a7c 100644 --- a/apps/cli/src/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/init/SIDE_EFFECTS.md @@ -73,13 +73,6 @@ failed to create config file: open supabase\config.toml: The file exists. Run supabase init --force to overwrite existing config file. ``` -When `--use-orioledb` is passed without `--experimental` (stderr; the second line is the generic debug hint appended on error): - -``` -required flag(s) "experimental" not set -Try rerunning the command with --debug to troubleshoot the error. -``` - When `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value other than `0` or `1` (stderr; the second line is the generic debug hint appended on error): ``` @@ -91,7 +84,7 @@ Try rerunning the command with --debug to troubleshoot the error. - Uses the invocation cwd directly and does not recurse upward looking for an existing project. - The `--force` flag overwrites an existing `supabase/config.toml`. -- The `--use-orioledb` flag sets `UseOrioleDB` in init params; requires `--experimental` flag. +- The `--use-orioledb` flag writes the OrioleDB version to `db.orioledb_version`; it does not require `--experimental`. - `SUPABASE_EXPERIMENTAL_STACK=1` opts the new project into the experimental stack backend: the written config includes `[experimental] stack = true` and omits the Docker-era default ports (API, database, shadow, pooler, Studio, mail UI, Functions inspector, and Analytics). diff --git a/apps/cli/src/commands/init/init.errors.ts b/apps/cli/src/commands/init/init.errors.ts index c9d7b03fef..0e2583b225 100644 --- a/apps/cli/src/commands/init/init.errors.ts +++ b/apps/cli/src/commands/init/init.errors.ts @@ -18,18 +18,3 @@ export class InitConfigExistsError extends Data.TaggedError("InitConfigExistsErr return actionability.provideFlags; } } - -/** - * `--use-orioledb` without `--experimental`. Reproduces the established required-flag error - * text verbatim: `required flag(s) "experimental" not set`. No suggestion — the text output - * layer's `fail` already appends the generic `--debug` hint when unset. - */ -export class InitExperimentalRequiredError extends Data.TaggedError( - "InitExperimentalRequiredError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} diff --git a/apps/cli/src/commands/init/init.handler.ts b/apps/cli/src/commands/init/init.handler.ts index a10d0d2518..49fa0ca9a2 100644 --- a/apps/cli/src/commands/init/init.handler.ts +++ b/apps/cli/src/commands/init/init.handler.ts @@ -6,23 +6,16 @@ import { experimentalFeatureEnv, resolveExperimentalFeature, } from "../../command-internal/experimental-feature.ts"; -import { ExperimentalFlag, WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; -import { InitConfigExistsError, InitExperimentalRequiredError } from "./init.errors.ts"; +import { WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; +import { InitConfigExistsError } from "./init.errors.ts"; import type { InitFlags } from "./init.command.ts"; export const init = Effect.fn("init")(function* (flags: InitFlags) { const output = yield* Output; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; - const experimental = yield* ExperimentalFlag; const workdir = yield* WorkdirFlag; - if (flags.useOrioledb && !experimental) { - return yield* new InitExperimentalRequiredError({ - message: `required flag(s) "experimental" not set`, - }); - } - const experimentalStack = yield* resolveExperimentalFeature({ feature: "stack", configValue: Effect.succeed(false), diff --git a/apps/cli/src/commands/init/init.integration.test.ts b/apps/cli/src/commands/init/init.integration.test.ts index bf9f451fca..0f7a5e2b17 100644 --- a/apps/cli/src/commands/init/init.integration.test.ts +++ b/apps/cli/src/commands/init/init.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import * as SmolToml from "smol-toml"; import { Cause, ConfigProvider, @@ -139,30 +140,29 @@ describe("init", () => { }); }); - it.live("requires --experimental when --use-orioledb is set, with cobra's exact wording", () => { + it.live.each([ + { experimental: false, label: "without --experimental" }, + { experimental: true, label: "with --experimental" }, + ])("writes OrioleDB config $label", ({ experimental }) => { const tempDir = tempRoot.current; return Effect.gen(function* () { - const { layer } = setup(tempDir, { experimental: false }); + const { layer, out } = setup(tempDir, { experimental }); - const exit = yield* init({ + yield* init({ interactive: false, useOrioledb: true, force: false, withVscodeWorkspace: false, withVscodeSettings: false, withIntellijSettings: false, - }).pipe(Effect.provide(layer), Effect.exit); - - const error = findFailure(exit); - expect(error["_tag"]).toBe("InitExperimentalRequiredError"); - expect(error["message"]).toBe(`required flag(s) "experimental" not set`); - expect(error["suggestion"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); - expect(yield* renderFailureToStderr(exit)).toEqual([ - `required flag(s) "experimental" not set\n`, - "Try rerunning the command with --debug to troubleshoot the error.\n", - ]); + const content = yield* readTextFile(tempDir, "supabase", "config.toml"); + expect(SmolToml.parse(content)).toMatchObject({ + db: { major_version: 17, orioledb_version: "17.11.0.002" }, + }); + expect(out.stdoutText).toBe("Finished supabase init.\n"); }); }); diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 3dd0155ed9..5361dd9270 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -1395,14 +1395,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ...config.db, port: values.dbPort, major_version: majorVersion, + // Matches the already env-overridden value used to select `postgresImage` above; + // `postgresExtraEnv` reads this and its sibling S3 fields for its + // `POSTGRES_INITDB_ARGS` branch. + orioledb_version: orioledbVersion, settings: resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), }, - // Matches the already env-overridden value used to select `postgresImage` above; - // `postgresExtraEnv` reads this and its sibling S3 fields for its - // `POSTGRES_INITDB_ARGS` branch. experimental: { ...config.experimental, - orioledb_version: orioledbVersion, s3_host: s3Host, s3_region: s3Region, s3_access_key: s3AccessKey, diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 4ed3356c11..208203cb30 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -4453,12 +4453,12 @@ content_path = "./supabase/templates/custom_notice.html" ); }); - describe("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION override", () => { + describe("SUPABASE_DB_ORIOLEDB_VERSION override", () => { it.live( "selects the OrioleDB Postgres image and enables the container's S3 env when set only via env", () => withEnvVar( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + "SUPABASE_DB_ORIOLEDB_VERSION", "16.0.0.1", Effect.gen(function* () { const { layer, child } = yield* setup(); @@ -4482,7 +4482,7 @@ content_path = "./supabase/templates/custom_notice.html" it.live("honors SUPABASE_EXPERIMENTAL_S3_HOST/_REGION/_ACCESS_KEY/_SECRET_KEY", () => withEnvVar( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + "SUPABASE_DB_ORIOLEDB_VERSION", "16.0.0.1", withEnvVar( "SUPABASE_EXPERIMENTAL_S3_HOST", diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index 7d9a266f2c..ef4dd89a5f 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -39,6 +39,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run \`SHOW # server_version;\` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. Leave empty to use the default engine. +orioledb_version = "__ORIOLEDB_VERSION__" [db.pooler] enabled = false @@ -392,8 +394,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "__ORIOLEDB_VERSION__" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "env(S3_HOST)" # Configures S3 bucket region, eg. us-east-1 @@ -464,7 +464,7 @@ export const INTELLIJ_DENO_TEMPLATE = `
`; -const ORIOLE_DB_VERSION = "15.1.0.150"; +const ORIOLE_DB_VERSION = "17.11.0.002"; const EXPERIMENTAL_STACK_INIT_FLAG = `# Use the new local stack backend for start, stop, and status, and for --local targets of db, migration, test db, gen types, inspect, and pull. stack = true diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 6dbb7073d6..744556105f 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -38,7 +38,7 @@ function renderExpectedGoEject(): string { return ( resolveGoTemplateEscapes(readVendoredTemplate("config.toml")) .replace("{{ .ProjectId }}", "demo-project") - .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") + .replace("{{ .Db.OrioleDBVersion }}", "17.11.0.002") // supabase init always opts new projects into pg-delta; the Go template // renders this from a flag only set on the init path. .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") diff --git a/apps/cli/src/shared/init/testdata/go-templates/config.toml b/apps/cli/src/shared/init/testdata/go-templates/config.toml index 07bacc0ade..c26043d5c9 100644 --- a/apps/cli/src/shared/init/testdata/go-templates/config.toml +++ b/apps/cli/src/shared/init/testdata/go-templates/config.toml @@ -40,6 +40,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. Leave empty to use the default engine. +orioledb_version = "{{ .Db.OrioleDBVersion }}" [db.pooler] enabled = false @@ -393,8 +395,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "{{ .Experimental.OrioleDBVersion }}" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "env(S3_HOST)" # Configures S3 bucket region, eg. us-east-1 diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 49daf10d9e..bbcc95c8ec 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -302,7 +302,6 @@ HealthCheckTimeoutError HostPostgresClientError ImagePrepullError InitConfigExistsError -InitExperimentalRequiredError InitParseSettingsError InspectCsvqError InspectMutuallyExclusiveFlagsError diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index bf021363eb..0047ef3cf9 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -1776,6 +1776,10 @@ "description": "The database major version to use. This has to be the same as your remote database's.", "default": 17 }, + "orioledb_version": { + "type": "string", + "description": "OrioleDB version of the Postgres image to use for the local database. Requires `major_version` 15 or 17." + }, "pooler": { "type": "object", "properties": { @@ -2418,7 +2422,7 @@ }, "orioledb_version": { "type": "string", - "description": "Postgres storage engine version for OrioleDB." + "description": "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB." }, "s3_host": { "type": "string", @@ -4291,6 +4295,10 @@ "description": "The database major version to use. This has to be the same as your remote database's.", "default": 17 }, + "orioledb_version": { + "type": "string", + "description": "OrioleDB version of the Postgres image to use for the local database. Requires `major_version` 15 or 17." + }, "pooler": { "type": "object", "properties": { @@ -4933,7 +4941,7 @@ }, "orioledb_version": { "type": "string", - "description": "Postgres storage engine version for OrioleDB." + "description": "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB." }, "s3_host": { "type": "string", diff --git a/packages/config/src/db.ts b/packages/config/src/db.ts index 28e1fdabe6..67f5d3219f 100644 --- a/packages/config/src/db.ts +++ b/packages/config/src/db.ts @@ -92,6 +92,13 @@ export const db = Schema.Struct({ tags, links: [links.postgres], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMajorVersion))), + orioledb_version: Schema.optionalKey( + Schema.String.annotate({ + description: + "OrioleDB version of the Postgres image to use for the local database. Requires `major_version` 15 or 17.", + tags, + }), + ), pooler: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultPoolerEnabled, diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 7f1d6e8466..92473d5e5e 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -495,6 +495,7 @@ describe("src/internal.ts export surface", () => { "decodeCliConfigDocumentForValidationEffect", "dualScopeProjectConfigPaths", "loadCliConfig", + "normalizeDeprecatedOrioleDBVersion", "projectConfigApiBlockKeys", "projectConfigMappingRows", "remoteNameForProjectRef", diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 83d0bbcbad..8086e802ed 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -47,7 +47,8 @@ export const experimental = Schema.Struct({ ), orioledb_version: Schema.optionalKey( Schema.String.annotate({ - description: "Postgres storage engine version for OrioleDB.", + description: + "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB.", tags, }), ), diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 42964d68c7..e7e4c33a36 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -31,5 +31,6 @@ export { writeCliConfigDocumentText, decodeCliConfigDocumentForValidationEffect, type DecodeCliConfigDocumentForValidationEffectOptions, + normalizeDeprecatedOrioleDBVersion, } from "./io.ts"; export { CliConfigWriteError } from "./errors.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index ce4c9284a5..dad9fc13c2 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -254,6 +254,103 @@ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocum return { document: normalized, deprecatedSections }; } +export interface NormalizedOrioleDBVersionDocument { + readonly document: unknown; + /** + * Dotted paths whose legacy `experimental.orioledb_version` was non-empty, e.g. + * `experimental.orioledb_version` or `remotes.staging.experimental.orioledb_version`. Emitted + * whether or not the value was actually promoted (an explicit `db.orioledb_version` still + * wins, but the legacy key is deprecated either way). + */ + readonly deprecatedPaths: ReadonlyArray; +} + +/** + * Moves a section's string `experimental.orioledb_version` out of `experimental`, promoting a + * non-empty value to `db.orioledb_version` when that is absent or `""` (the init template always + * writes `""`). Non-string values, a non-empty `db.orioledb_version`, and a non-table `db` are + * left untouched so schema validation still sees them. + */ +function promoteOrioleDBVersion(section: Record): { + readonly section: Record; + readonly warned: boolean; +} { + const experimental = section.experimental; + if (!isObject(experimental) || !("orioledb_version" in experimental)) { + return { section, warned: false }; + } + const legacyValue = experimental.orioledb_version; + if (typeof legacyValue !== "string") { + return { section, warned: false }; + } + const legacyNonEmpty = legacyValue.length > 0; + + const normalizedExperimental = { ...experimental }; + delete normalizedExperimental.orioledb_version; + const normalizedSection: Record = { + ...section, + experimental: normalizedExperimental, + }; + + if (!legacyNonEmpty) { + return { section: normalizedSection, warned: false }; + } + if ("db" in section && !isObject(section.db)) { + return { section: normalizedSection, warned: true }; + } + + const db = isObject(section.db) ? section.db : undefined; + const dbValue = db?.orioledb_version; + const dbCanBePromotedOver = dbValue === undefined || dbValue === ""; + if (dbCanBePromotedOver) { + normalizedSection.db = { ...db, orioledb_version: legacyValue }; + } + + return { section: normalizedSection, warned: true }; +} + +/** + * Rewrites the deprecated `experimental.orioledb_version` (top-level and per `[remotes.*]`) to + * `db.orioledb_version`, following the same shape as {@link normalizeDeprecatedSMTPSections}. + * Exposed via `@supabase/config/internal` so `apps/cli`'s raw TOML reader + * (`db-config.toml-read.ts`) can apply the same precedence before its own `[remotes.*]` merge. + */ +export function normalizeDeprecatedOrioleDBVersion( + document: unknown, +): NormalizedOrioleDBVersionDocument { + if (!isObject(document)) { + return { document, deprecatedPaths: [] }; + } + const deprecatedPaths: Array = []; + let normalized: Record = { ...document }; + + const top = promoteOrioleDBVersion(normalized); + normalized = top.section; + if (top.warned) { + deprecatedPaths.push("experimental.orioledb_version"); + } + + if (isObject(normalized.remotes)) { + normalized = { + ...normalized, + remotes: Object.fromEntries( + Object.entries(normalized.remotes).map(([name, remote]) => { + if (!isObject(remote)) { + return [name, remote]; + } + const result = promoteOrioleDBVersion(remote); + if (result.warned) { + deprecatedPaths.push(`remotes.${name}.experimental.orioledb_version`); + } + return [name, result.section]; + }), + ), + }; + } + + return { document: normalized, deprecatedPaths }; +} + interface NormalizedExternalProvidersDocument { readonly document: unknown; /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled`. */ @@ -477,7 +574,10 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( try: () => parseCliConfigDocument(content, format), catch: (cause) => new CliConfigParseError({ path: filePath, format, cause }), }); - const { document: normalized, deprecatedSections } = normalizeDeprecatedSMTPSections(document); + const { document: smtpNormalized, deprecatedSections } = + normalizeDeprecatedSMTPSections(document); + const { document: normalized, deprecatedPaths: deprecatedOrioleDBPaths } = + normalizeDeprecatedOrioleDBVersion(smtpNormalized); // Warn on stderr, writing directly to the real console (bypassing whatever `Console.Console` // is ambient) so a caller wrapping this in a deferred/buffered console can't delay or // swallow it. @@ -487,6 +587,12 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( `WARN: config section [${section}] is deprecated. Please use [${replacement}] instead.`, ).pipe(Effect.provideService(Console.Console, globalThis.console)); } + for (const path of deprecatedOrioleDBPaths) { + const replacement = path.replace(/experimental\.orioledb_version$/, "db.orioledb_version"); + yield* Console.error(`WARN: ${path} is deprecated. Please use ${replacement} instead.`).pipe( + Effect.provideService(Console.Console, globalThis.console), + ); + } // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env before schema // decode, since a numeric/boolean field would otherwise crash the strict decoder on a string. diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 16151f74eb..a6a219c264 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -2413,6 +2413,203 @@ port = 54324 }); }); +describe("config io deprecated experimental.orioledb_version back-compat", () => { + let warnings: Array = []; + let errorSpy: MockInstance | undefined; + + function captureWarnings() { + warnings = []; + errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + } + + afterEach(() => { + errorSpy?.mockRestore(); + errorSpy = undefined; + }); + + async function loadToml(contents: string) { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(path, contents); + try { + return await runConfigEffect(loadCliConfigFile(path)); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("promotes a non-empty legacy experimental.orioledb_version to db.orioledb_version", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + + expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); + expect(loaded.config.experimental.orioledb_version).toBeUndefined(); + expect(loaded.document).not.toHaveProperty("experimental.orioledb_version"); + expect( + warnings.some((m) => + m.includes( + "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); + + test("does not warn and stays absent when the legacy value is an empty string", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[experimental] +orioledb_version = "" +`, + ); + + expect(loaded.config.db.orioledb_version).toBeUndefined(); + expect(loaded.config.experimental.orioledb_version).toBeUndefined(); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("promotes a non-empty legacy value when db.orioledb_version is present but empty", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[db] +orioledb_version = "" + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + + expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); + expect( + warnings.some((m) => + m.includes( + "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); + + test("an explicit db.orioledb_version wins over a non-empty legacy value, but still warns", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[db] +orioledb_version = "17.0.0.1" + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + + expect(loaded.config.db.orioledb_version).toBe("17.0.0.1"); + expect( + warnings.some((m) => + m.includes( + "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); + + test("rejects a non-string legacy experimental.orioledb_version instead of silently discarding it", async () => { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + path, + `project_id = "abc123" + +[experimental] +orioledb_version = 1 +`, + ); + try { + const exit = await Effect.runPromiseExit( + loadCliConfigFile(path).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value._tag).toBe("CliConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("rejects a non-string canonical db.orioledb_version beside a valid legacy value", async () => { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + path, + `project_id = "abc123" + +[db] +orioledb_version = 1 + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + try { + const exit = await Effect.runPromiseExit( + loadCliConfigFile(path).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value._tag).toBe("CliConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("normalizes a deprecated remotes.*.experimental.orioledb_version", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[remotes.staging] +project_id = "stagingrefaaaaaaaaaa" + +[remotes.staging.experimental] +orioledb_version = "15.1.0.150" +`, + ); + + const staging = loaded.config.remotes.staging; + expect(staging?.db?.orioledb_version).toBe("15.1.0.150"); + expect( + warnings.some((m) => + m.includes( + "WARN: remotes.staging.experimental.orioledb_version is deprecated. Please use remotes.staging.db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); +}); + describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { let warnings: Array = []; let errorSpy: MockInstance | undefined; diff --git a/packages/config/src/project-config/hosted-sections.ts b/packages/config/src/project-config/hosted-sections.ts index 927d39fcbf..d8e924bcb6 100644 --- a/packages/config/src/project-config/hosted-sections.ts +++ b/packages/config/src/project-config/hosted-sections.ts @@ -21,7 +21,9 @@ export type HostedSectionKey = (typeof HOSTED_SECTION_KEYS)[number]; * `ProjectConfig`'s shape, `ProjectConfigSchema`/`toProjectConfigJsonSchema`, and * `fromConfigDocument`'s output alike: local bind ports/TLS overrides, `db.pooler`'s * `enabled`/`port`, the `db.migrations`/`db.seed` subtrees, every config-side `realtime.*` field, - * and local-only `experimental.*` engine/backend selection. + * `db.orioledb_version` (plus its deprecated `experimental.orioledb_version` alias — a document + * that hasn't gone through the loader's deprecation normalization can still carry it), and + * local-only `experimental.*` engine/backend selection. * * `db.major_version` and `db.pooler`'s other three fields (`pool_mode`, `default_pool_size`, * `max_client_conn`) are real hosted facts and excluded from this list, so `config @@ -38,6 +40,7 @@ export const DOCUMENT_ONLY_LOCAL_PATHS = [ ["db", "port"], ["db", "shadow_port"], ["db", "health_timeout"], + ["db", "orioledb_version"], ["db", "pooler", "enabled"], ["db", "pooler", "port"], ["db", "migrations"], diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index 4ba7089baf..9bdf80ee87 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -579,6 +579,7 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { shadow_port: 2, health_timeout: "5m", major_version: 15, + orioledb_version: "1.0", pooler: { enabled: true, port: 7777, pool_mode: "session" }, migrations: { enabled: false }, seed: { enabled: false }, From 8ae22bcba2fbe5b9e5de2c42c54bf332463f059a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 13:22:49 +0000 Subject: [PATCH 55/71] feat(stack): serve Studio's MCP server at /mcp on the API listener (CLI-2546) (#6898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary With the stack backend, the local MCP server is not reachable at the API URL: the shared API listener has no `/mcp` route, so MCP is only served on Studio's own port under its internal `/api/mcp` path. The legacy stack exposes it at `/mcp` (Kong maps `/mcp` to Studio's `/api/mcp`), which is the documented local address, `http://127.0.0.1:54321/mcp` with the default configuration. - Studio's HTTP endpoint contributes a join-only `/mcp` → `/api/mcp` route to the shared API listener. It never claims or asserts the API port, is queued until a claiming endpoint binds, and is removed when Studio's namespace closes or is released. - The route is derived when Studio registers, so nothing is persisted and existing stacks gain it on their next start. A request to `/mcp` wakes a sleeping Studio. - Exact-prefix requests now map to the upstream prefix itself instead of gaining a trailing slash (`/mcp` → `/api/mcp`), matching how the legacy gateway strips route paths. A follow-up to #6894 will report `MCP_URL` as `/mcp`. --------- Co-authored-by: Julien Goux --- .../stack/src/HttpProxy.integration.test.ts | 69 +++++ packages/stack/src/HttpProxy.ts | 5 +- .../stack/src/Network.integration.test.ts | 284 +++++++++++++++++- packages/stack/src/Network.ts | 73 ++++- packages/stack/src/Owner.ts | 3 + packages/stack/src/host/Endpoints.ts | 6 + 6 files changed, 426 insertions(+), 14 deletions(-) diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index 16c6f3eb8e..3123e22a91 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -158,6 +158,75 @@ it.live("keeps the retained listener and remaining route after one route is remo ).pipe(Effect.provide(NodeServices.layer)), ); +it.live("rewrites an exact-prefix request to the upstream prefix verbatim", () => + Effect.scoped( + Effect.gen(function* () { + const backend = createServer((request, response) => response.end(request.url)); + const backendAddress = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + const route = (id: string, prefix: string, upstreamPrefix: string): HttpRoute => ({ + id, + prefix, + upstreamPrefix, + target: Effect.succeed(backendAddress), + }); + yield* proxy.setRoutes([ + route("mcp", "/mcp", "/api/mcp"), + route("storage-s3", "/storage/v1/s3", "/s3"), + route("realtime-api", "/realtime/v1/api", "/api"), + ]); + const send = (path: string) => + request(proxy.port, path, new Uint8Array()).pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(new TextDecoder().decode((yield* send("/mcp")).body)).toBe("/api/mcp"); + expect(new TextDecoder().decode((yield* send("/mcp?read_only=true")).body)).toBe( + "/api/mcp?read_only=true", + ); + expect(new TextDecoder().decode((yield* send("/mcp/x")).body)).toBe("/api/mcp/x"); + expect((yield* send("/mcpx")).status).toBe(404); + expect(new TextDecoder().decode((yield* send("/storage/v1/s3")).body)).toBe("/s3"); + expect(new TextDecoder().decode((yield* send("/realtime/v1/api")).body)).toBe("/api"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("streams a joined MCP route's request and response through the exact upstream path", () => + Effect.scoped( + Effect.gen(function* () { + let seenUrl: string | undefined; + let seenBody = ""; + const backend = createServer((request, response) => { + seenUrl = request.url; + request.on("data", (chunk: Buffer) => (seenBody += chunk.toString())); + request.on("end", () => { + response.writeHead(200, { "content-type": "text/plain" }); + response.write("chunk-1"); + response.end("chunk-2"); + }); + }); + const backendAddress = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([ + { + id: "mcp", + prefix: "/mcp", + upstreamPrefix: "/api/mcp", + target: Effect.succeed(backendAddress), + }, + ]); + const response = yield* request( + proxy.port, + "/mcp?read_only=true", + new TextEncoder().encode("mcp-request-body"), + ).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)); + expect(seenUrl).toBe("/api/mcp?read_only=true"); + expect(seenBody).toBe("mcp-request-body"); + expect(new TextDecoder().decode(response.body)).toBe("chunk-1chunk-2"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + it.live("interrupts target acquisition quietly when a waiting client disconnects", () => { const logs: Array = []; return Effect.scoped( diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index 881c9c28b0..4b4e5f0485 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -136,7 +136,10 @@ const pathFor = (request: IncomingMessage, route: HttpRoute) => { const path = route.upstreamPrefix === undefined ? pathname - : `${route.upstreamPrefix.replace(/\/$/u, "")}${suffix.startsWith("/") ? suffix : `/${suffix}`}`; + : // Exact-path upstreams such as Studio's `/api/mcp` redirect a trailing slash. + suffix === "" + ? route.upstreamPrefix + : `${route.upstreamPrefix.replace(/\/$/u, "")}${suffix.startsWith("/") ? suffix : `/${suffix}`}`; return `${path}${rewriteQuery(query, route)}`; }; diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index 47fb745cb9..680424c0ad 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Data, Deferred, Effect, FileSystem, Layer, Path, Ref } from "effect"; +import { Context, Data, Deferred, Effect, Fiber, FileSystem, Layer, Path, Ref } from "effect"; import * as Net from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- fixture retains an idle HTTP connection. import { createServer } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. import { HttpClient } from "effect/unstable/http"; @@ -78,6 +78,22 @@ const endpoint = (target: { host: string; port: number }, enabled: Effect.Effect enabled, }); +/** A dedicated HTTP endpoint that additionally joins the shared API listener, like Studio's `http`. */ +const joinEndpoint = (target: { host: string; port: number }, enabled: Effect.Effect) => ({ + ...endpoint(target, enabled), + join: [{ prefix: "/mcp", upstreamPrefix: "/api/mcp" }], +}); + +const claimant = ( + id: string, + target: { host: string; port: number }, + enabled: Effect.Effect, + port: number | "auto" = "auto", +) => ({ + id, + endpoints: { api: { ...endpoint(target, enabled), port, shared: [{ prefix: "/rest" }] } }, +}); + it.live("retains dedicated assignments across network reopen", () => Effect.scoped( Effect.gen(function* () { @@ -323,6 +339,272 @@ it.live("releases dedicated HTTP activity after the response while keep-alive st ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); +it.live( + "queues a joined route before any claimant binds, then serves it on the claimant's own port", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-fixed-port-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Effect.succeed(true)) }, + }); + yield* studio.bind; + // The join route is queued, not installed: it never claims or asserts the shared "api" + // port, so no shared listener or claim exists yet, whatever port a later claimant picks. + const beforeClaimant = yield* state.read("stack"); + expect(beforeClaimant?.ports.some((claim) => claim.key === "api")).toBe(false); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp?read_only=true")).toBe( + "backend:/api/mcp?read_only=true", + ); + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("drops a queued join route when its namespace closes before any claimant binds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-cancel-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Effect.succeed(false)) }, + }); + yield* studio.bind; + yield* studio.close; + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const address = yield* rest.address("api", "host"); + const response = yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/mcp`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(response.status).toBe(404); + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("stays idempotent across repeated binds of the joining namespace", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-idempotent-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Effect.succeed(true)) }, + }); + yield* studio.bind; + yield* studio.bind; + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("keeps a namespace's own shared route when its join endpoint also binds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "network-join-shares-namespace-", + }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const combo = yield* network.register({ + id: "combo", + endpoints: { + api: { ...endpoint(target, Effect.succeed(true)), shared: [{ prefix: "/rest" }] }, + http: joinEndpoint(target, Effect.succeed(true)), + }, + }); + yield* combo.bind; + const address = yield* combo.address("api", "host"); + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* combo.bind; + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("re-adds a joined route after its namespace closes and rebinds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-rebind-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const enabled = yield* Ref.make(true); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Ref.get(enabled)) }, + }); + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Ref.set(enabled, false); + yield* studio.close; + const closedResponse = yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/mcp`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(closedResponse.status).toBe(404); + yield* Ref.set(enabled, true); + yield* studio.bind; + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("never restores a joined route once its namespace is released", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-release-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const enabled = yield* Ref.make(true); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Ref.get(enabled)) }, + }); + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Ref.set(enabled, false); + yield* studio.release; + const failure = yield* studio.bind.pipe(Effect.flip); + expect(failure.operation).toBe("bind"); + const releasedResponse = yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/mcp`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(releasedResponse.status).toBe(404); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live( + "keeps a joined route working after the last claimant closes and closes the listener once it is gone too", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-last-close-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const restEnabled = yield* Ref.make(true); + const studioEnabled = yield* Ref.make(true); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Ref.get(restEnabled))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Ref.get(studioEnabled)) }, + }); + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Ref.set(restEnabled, false); + yield* rest.close; + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + expect( + (yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/rest`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + )).status, + ).toBe(404); + // A served keep-alive request proves the listener accepted and tracks this socket, so its + // closure is observed directly instead of by reprobing the port. + const probe = yield* Effect.callback((resume) => { + const connection = Net.createConnection({ host: address.host, port: address.port }); + connection.once("connect", () => { + connection.write( + `GET /mcp HTTP/1.1\r\nHost: ${address.host}:${address.port}\r\nConnection: keep-alive\r\n\r\n`, + ); + }); + connection.once("data", () => resume(Effect.succeed(connection))); + connection.on("error", (cause) => + resume(Effect.fail(new FixtureError({ message: cause.message }))), + ); + return Effect.sync(() => connection.destroy()); + }); + const probeClosed = yield* Effect.callback((resume) => { + probe.once("close", () => resume(Effect.void)); + return Effect.sync(() => probe.destroy()); + }).pipe(Effect.forkChild); + yield* Ref.set(studioEnabled, false); + yield* studio.close; + yield* Fiber.join(probeClosed).pipe(Effect.timeout("2 seconds")); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("wakes a sleeping backend when a request reaches its joined route", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-wake-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const woken = yield* Deferred.make(); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { + http: { + ...joinEndpoint(target, Effect.succeed(true)), + // Mirrors the orchestrator's acquire-then-resolve path: the target effect itself wakes + // the instance on demand instead of pointing at an already-running backend. + backend: Deferred.succeed(woken, undefined).pipe(Effect.as(target)), + }, + }, + }); + yield* studio.bind; + expect(yield* Deferred.isDone(woken)).toBe(false); + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Deferred.await(woken).pipe(Effect.timeout("2 seconds")); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live("addresses docker runtime endpoints through the stack host alias", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index bb945595fe..74d8381531 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -7,13 +7,21 @@ import { makeHttpProxy, type HttpProxy, type HttpRoute } from "./HttpProxy.ts"; export type NetworkRuntime = "native" | "docker" | "podman"; +type RouteContribution = Pick< + HttpRoute, + "prefix" | "upstreamPrefix" | "upstreamHost" | "keyRewrite" +>; + export interface NetworkEndpoint { readonly protocol: "tcp" | "http"; readonly port: number | "auto"; readonly backend: Effect.Effect; - readonly shared?: ReadonlyArray< - Pick - >; + readonly shared?: ReadonlyArray; + /** + * Routes a dedicated endpoint additionally contributes to the shared API listener, without + * claiming the shared port itself. Installed when the shared listener exists, queued otherwise. + */ + readonly join?: ReadonlyArray; readonly enabled: Effect.Effect; } @@ -78,6 +86,8 @@ const makeNetwork = (options: { } | undefined >(undefined); + // Join routes queued before any claiming endpoint has created the shared listener. + const pendingJoins = yield* Ref.make>([]); const listenHost = options.runtime === "native" ? "127.0.0.1" : "0.0.0.0"; const hostAddress = "127.0.0.1"; @@ -88,6 +98,44 @@ const makeNetwork = (options: { ? DOCKER_HOST_ALIAS : "host.containers.internal"; + const routeKey = (route: Pick) => `${route.id}:${route.prefix}`; + + /** Maps one endpoint's route contributions to the shared listener's `HttpRoute` shape. */ + const toHttpRoutes = ( + id: string, + contributions: ReadonlyArray, + backend: NetworkEndpoint["backend"], + ): ReadonlyArray => + contributions.map((route) => ({ + id, + prefix: route.prefix, + upstreamPrefix: route.upstreamPrefix, + upstreamHost: route.upstreamHost, + ...(route.keyRewrite === undefined ? {} : { keyRewrite: route.keyRewrite }), + target: backend, + })); + + /** Installs a namespace's joined routes onto the shared listener, or queues them if it does not exist yet. */ + const installJoin = Effect.fn("Network.installJoin")(function* ( + routeId: string, + join: NonNullable, + backend: NetworkEndpoint["backend"], + ) { + const routes = toHttpRoutes(routeId, join, backend); + const ownKeys = new Set(routes.map(routeKey)); + const current = yield* Ref.get(shared); + if (current === undefined) { + yield* Ref.update(pendingJoins, (existing) => [ + ...existing.filter((route) => !ownKeys.has(routeKey(route))), + ...routes, + ]); + return; + } + const next = [...current.routes.filter((route) => !ownKeys.has(routeKey(route))), ...routes]; + yield* current.proxy.setRoutes(next); + yield* Ref.set(shared, { ...current, routes: next }); + }); + const register = Effect.fn("Network.register")(function* ({ id, endpoints, @@ -160,23 +208,19 @@ const makeNetwork = (options: { ); if (endpoint.shared !== undefined && result.listener.proxy !== undefined) { const previous = yield* Ref.get(shared); + let seeded: ReadonlyArray = []; + if (previous === undefined) seeded = yield* Ref.get(pendingJoins); const current = previous ?? { claim: result.port, proxy: result.listener.proxy, - routes: [], + routes: seeded, scope: endpointScope, }; if (previous !== undefined) yield* Scope.close(endpointScope, Exit.void); + else if (seeded.length > 0) yield* Ref.set(pendingJoins, []); const routes = [ ...current.routes, - ...endpoint.shared.map((route) => ({ - id, - prefix: route.prefix, - upstreamPrefix: route.upstreamPrefix, - upstreamHost: route.upstreamHost, - ...(route.keyRewrite === undefined ? {} : { keyRewrite: route.keyRewrite }), - target: endpoint.backend, - })), + ...toHttpRoutes(id, endpoint.shared, endpoint.backend), ]; yield* current.proxy.setRoutes(routes); yield* Ref.set(shared, { ...current, routes }); @@ -185,6 +229,8 @@ const makeNetwork = (options: { new Map(current).set(name, endpointScope), ); } + if (endpoint.join !== undefined) + yield* installJoin(id, endpoint.join, endpoint.backend); yield* Ref.update(bound, (current) => new Map(current).set(name, { name, @@ -207,6 +253,9 @@ const makeNetwork = (options: { if (yield* Ref.get(closed)) return; for (const endpoint of Object.values(endpoints)) if (yield* endpoint.enabled) return; + // Drop this namespace's queued join routes so a later listener never resurrects them. + yield* Ref.update(pendingJoins, (routes) => routes.filter((route) => route.id !== id)); + const current = yield* Ref.get(shared); let remainingRoutes = current?.routes ?? []; if (current !== undefined) { diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index a47a685e8f..62cfee1a72 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -35,6 +35,7 @@ import { credentialsFor, endpointNames, endpointPort, + joinRoutes, outputsFor, publicUrl, sharedRoutes, @@ -358,6 +359,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { const endpoints = Object.fromEntries( endpointNames(initial).map((name) => { const shared = sharedRoutes(initial, name, routeKeys); + const join = joinRoutes(initial, name); const endpoint: NetworkEndpoint = { protocol: name === "http" ? "http" : "tcp", port: endpointPort(initial, name), @@ -372,6 +374,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { ), enabled, ...(shared === undefined ? {} : { shared }), + ...(join === undefined ? {} : { join }), }; return [name, endpoint]; }), diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index b07c7657fa..3a170071f8 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -88,6 +88,12 @@ export const sharedRoutes = ( } }; +/** Shared-listener routes a dedicated HTTP endpoint also serves: Studio's MCP server at `/mcp`. */ +export const joinRoutes = (creation: ServiceCreation, name: string): NetworkEndpoint["join"] => + creation.service === "studio" && name === "http" + ? [{ prefix: "/mcp", upstreamPrefix: "/api/mcp" }] + : undefined; + /** Translates a launched runtime's endpoint into the proxy's backend address. */ export const backendAddress = ( endpoint: ServiceEndpoint, From 8a7963e31241d7bc947c33a22cfac2459ddeb01d Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 30 Sep 2026 14:01:14 +0000 Subject: [PATCH 56/71] feat(cli): upgrade pg-delta to 1.0.0-alpha.56 (#6913) ## Summary **TL;DR:** The CLI now bundles `@supabase/pg-delta@1.0.0-alpha.56` and `@supabase/pg-topo@1.0.0-alpha.7`. `db diff`, `db pull`, and declarative sync pick up the engine fixes from alpha.53 through alpha.56. A partition-key type change drops and recreates the table, because Postgres rejects an in-place `ALTER` on a key column. ### Before ```mermaid flowchart LR cmd["db diff / pull / declarative sync"] --> old["pg-delta alpha.52"] old --> fail["enum cast, grants, pgmq SCHEMA, range order, and partition-key ALTER fail or churn"] ``` ### After ```mermaid flowchart LR cmd["db diff / pull / declarative sync"] --> new["pg-delta alpha.56"] new --> ok["those plans converge"] new --> drop["partition-key change drops the table"] ``` ## Why `develop` was still on `1.0.0-alpha.52`. The published alphas since then fix enum retypes through `text`, domain `NOT NULL` on PostgreSQL 17, column and identity-sequence grants, `pgmq` without a redundant `SCHEMA` clause, range-type ordering, and partition-key changes that previously failed apply. Dogfood on a linked staging project converged for push, an empty linked diff, a one-column pull, and declarative generate then sync. ## What changed - Bump `@supabase/pg-delta` from `1.0.0-alpha.52` to `1.0.0-alpha.56`. - Bump peer `@supabase/pg-topo` from `1.0.0-alpha.6` to `1.0.0-alpha.7`, which alpha.56 requires. - Point `minimumReleaseAgeExclude` at those two versions. ## Linked issue Supabase maintainer change; no public issue to close. --- apps/cli/package.json | 4 ++-- pnpm-lock.yaml | 24 ++++++++++++------------ pnpm-workspace.yaml | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 774a49e804..c0251fc102 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,8 +57,8 @@ "@napi-rs/keyring": "^1.3.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", - "@supabase/pg-delta": "1.0.0-alpha.52", - "@supabase/pg-topo": "1.0.0-alpha.6", + "@supabase/pg-delta": "1.0.0-alpha.56", + "@supabase/pg-topo": "1.0.0-alpha.7", "@supabase/stack": "workspace:*", "@supabase/supabase-js": "catalog:", "@supabase/typegen": "0.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a984fded6..8d31bdf13a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,11 +331,11 @@ importers: specifier: workspace:* version: link:../../packages/config '@supabase/pg-delta': - specifier: 1.0.0-alpha.52 - version: 1.0.0-alpha.52(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0) + specifier: 1.0.0-alpha.56 + version: 1.0.0-alpha.56(@supabase/pg-topo@1.0.0-alpha.7(supports-color@7.2.0))(supports-color@7.2.0) '@supabase/pg-topo': - specifier: 1.0.0-alpha.6 - version: 1.0.0-alpha.6(supports-color@7.2.0) + specifier: 1.0.0-alpha.7 + version: 1.0.0-alpha.7(supports-color@7.2.0) '@supabase/stack': specifier: workspace:* version: link:../../packages/stack @@ -2889,18 +2889,18 @@ packages: resolution: {integrity: sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==} engines: {node: '>=22.0.0'} - '@supabase/pg-delta@1.0.0-alpha.52': - resolution: {integrity: sha512-mp1knQPI3x06O2YaqZqaGQzoI7ZYs7rlSYgdYCkX0ktkEj5VvMb+kT64Oatg3hElr2D4Jslqg8XcgOVlT6gqCQ==} + '@supabase/pg-delta@1.0.0-alpha.56': + resolution: {integrity: sha512-Cerar16vqsLV4dO/IxjDbKBSItB5936JOEE46R/gUjey1hHztkfdcgP0GCoM8i510nxyAT/lUDCdXFraRheizQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - '@supabase/pg-topo': ^1.0.0-alpha.6 + '@supabase/pg-topo': ^1.0.0-alpha.7 peerDependenciesMeta: '@supabase/pg-topo': optional: true - '@supabase/pg-topo@1.0.0-alpha.6': - resolution: {integrity: sha512-bejMubS6l1E3/8AUpJ3a6kG9DTKbCq5oMoDYgfzlh4VEPdX34wd0U32OglzfuSBojlqU1YpDIpJp2m9Qq3IrnQ==} + '@supabase/pg-topo@1.0.0-alpha.7': + resolution: {integrity: sha512-agvnNRKOSs0A02mW95KW+AKaF0YR/uk0GFBoCfQbGjjdgawk/I2O2wlMWQvbryzsuDr0unWbdPdj3Aah5FexLA==} '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} @@ -8668,7 +8668,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/pg-delta@1.0.0-alpha.52(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@supabase/pg-delta@1.0.0-alpha.56(@supabase/pg-topo@1.0.0-alpha.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@types/debug': 4.1.13 '@types/pg': 8.23.1 @@ -8676,12 +8676,12 @@ snapshots: pg: 8.23.0 pg-connection-string: 2.14.0 optionalDependencies: - '@supabase/pg-topo': 1.0.0-alpha.6(supports-color@7.2.0) + '@supabase/pg-topo': 1.0.0-alpha.7(supports-color@7.2.0) transitivePeerDependencies: - pg-native - supports-color - '@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0)': + '@supabase/pg-topo@1.0.0-alpha.7(supports-color@7.2.0)': dependencies: '@pgsql/traverse': 17.2.6(supports-color@7.2.0) plpgsql-parser: 0.5.16(supports-color@7.2.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b9b74bd071..3f28d1bc19 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -108,8 +108,8 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-rc.112" - "@effect/sql-pg@4.0.0-rc.112" - "@effect/vitest@4.0.0-rc.112" - - "@supabase/pg-delta@1.0.0-alpha.52" - - "@supabase/pg-topo@1.0.0-alpha.6" + - "@supabase/pg-delta@1.0.0-alpha.56" + - "@supabase/pg-topo@1.0.0-alpha.7" - "@supabase/postgrest-typegen@0.3.1" - "@supabase/typegen@0.2.1" - "effect@4.0.0-rc.112" From 2799c9f01fb6de82f4cf295be8da48ea8fba03eb Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:36:01 +0000 Subject: [PATCH 57/71] refactor(cli): cover `/shared` init, feedback and services with effect lint (CLI-2559) (#6911) ## TL;DR brings the `/shared` init, feedback and services areas under the effect lint ## whats introduced? effect lint applied to the three areas and their tests: - `shared/init/**`, `shared/feedback/**` and `shared/services/**` allow list entries, with the generated `feedback/database.types.ts` kept out - `slimImagesEnabled` reads `SUPABASE_USE_SLIM_IMAGES` through `Config` from the process environment instead of `process.env`, so a project value or a failing provider still cannot change it - the slim helpers, the `start` container specs and the image resolvers take the flag as an argument instead of reading it themselves, and every caller resolves the same images as before - `project-init` writes the merged IDE settings through a schema encoder with the same two space output - `services.shared` yields `ServiceVersionNotFoundError` directly instead of wrapping it in `Effect.fail` - tests use scoped temp dirs, `FileSystem` and scoped servers instead of `node:fs` and async helpers, and pass the slim flag explicitly ## ref: - closes: CLI-2559 --- .oxlintrc.effect.json | 4 + .../command-internal/db-bootstrap/db-setup.ts | 10 +- .../db-bootstrap/pinned-image.ts | 2 + .../db-bootstrap/pinned-image.unit.test.ts | 51 +- .../db-bootstrap/shadow-cache.ts | 8 +- apps/cli/src/command-internal/db-image.ts | 7 +- .../command-internal/db-image.unit.test.ts | 2 +- .../command-internal/edge-runtime-image.ts | 16 +- .../edge-runtime-image.unit.test.ts | 8 +- .../commands/db/diff/diff.integration.test.ts | 2 +- apps/cli/src/commands/db/diff/pgadmin-diff.ts | 3 +- .../download/download.integration.test.ts | 7 +- .../functions/serve/serve.integration.test.ts | 3 +- .../src/commands/gen/types/types.e2e.test.ts | 5 +- .../squash/squash.integration.test.ts | 5 +- .../src/commands/services/services.handler.ts | 2 + .../services/services.integration.test.ts | 13 +- .../commands/start/services/gotrue.service.ts | 4 +- .../services/gotrue.service.unit.test.ts | 9 +- .../start/services/logflare.service.ts | 4 +- .../services/logflare.service.unit.test.ts | 13 +- .../start/services/realtime.service.ts | 4 +- .../services/realtime.service.unit.test.ts | 9 +- .../start/services/storage.service.ts | 4 +- .../services/storage.service.unit.test.ts | 9 +- .../start/services/supavisor.service.ts | 4 +- .../services/supavisor.service.unit.test.ts | 9 +- .../commands/start/services/vector.service.ts | 4 +- .../services/vector.service.unit.test.ts | 8 +- apps/cli/src/commands/start/start.gates.ts | 5 +- apps/cli/src/commands/start/start.handler.ts | 10 +- .../start/start.lifecycle.e2e.test.ts | 5 +- .../start/start.services.unit.test.ts | 37 +- .../feedback-client.integration.test.ts | 33 +- apps/cli/src/shared/functions/deploy.ts | 3 +- apps/cli/src/shared/functions/download.ts | 3 +- .../src/shared/functions/functions.shared.ts | 4 +- .../functions/functions.shared.unit.test.ts | 9 +- .../functions/serve-main-offline.e2e.test.ts | 9 +- apps/cli/src/shared/functions/serve.ts | 3 +- .../project-init.modes.integration.test.ts | 89 ++- .../init/project-init.templates.unit.test.ts | 121 ++-- apps/cli/src/shared/init/project-init.ts | 8 +- .../services/dockerfile-go-sync.unit.test.ts | 24 +- .../src/shared/services/dockerfile-images.ts | 4 +- .../src/shared/services/services.shared.ts | 35 +- .../services/services.shared.unit.test.ts | 522 +++++++++--------- apps/cli/src/shared/services/slim-images.ts | 26 +- .../shared/services/slim-images.unit.test.ts | 82 +-- 49 files changed, 662 insertions(+), 599 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index a222728c34..0e8b78b348 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -11,17 +11,21 @@ "!apps/cli/src/shared/auth/**", "!apps/cli/src/shared/compute/**", "!apps/cli/src/shared/config/**", + "!apps/cli/src/shared/feedback/**", "!apps/cli/src/shared/functions/functions-docker.ts", "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", "!apps/cli/src/shared/git/**", + "!apps/cli/src/shared/init/**", "!apps/cli/src/shared/issue/**", "!apps/cli/src/shared/runtime/**", + "!apps/cli/src/shared/services/**", "!apps/cli/src/shared/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", + "apps/cli/src/shared/feedback/database.types.ts", "!apps/cli/tests/helpers/branches-live.ts", "!apps/cli/tests/helpers/compute.ts", "!apps/cli/tests/helpers/migration-live.ts", diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index bab4a35bc1..08ba19d556 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -14,6 +14,7 @@ import { Data, Effect, type FileSystem, Option, type Path, Schedule } from "effe import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { Output } from "../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { @@ -139,11 +140,12 @@ export interface StartDbSetupImages { */ function resolveDbSetupImages( serviceVersionOverrides: LocalServiceVersionOverrides, + slim: boolean, ): StartDbSetupImages { return { - realtime: resolvePinnedImage("realtime", "realtime", serviceVersionOverrides), - storage: resolvePinnedImage("storage", "storage", serviceVersionOverrides), - auth: resolvePinnedImage("gotrue", "auth", serviceVersionOverrides), + realtime: resolvePinnedImage("realtime", "realtime", serviceVersionOverrides, slim), + storage: resolvePinnedImage("storage", "storage", serviceVersionOverrides, slim), + auth: resolvePinnedImage("gotrue", "auth", serviceVersionOverrides, slim), }; } @@ -169,7 +171,7 @@ export const resolveDbSetupPrelude = (setup: { const output = yield* Output; yield* output.raw("Initialising schema...\n", "stderr"); const jwks = setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; - const images = resolveDbSetupImages(setup.serviceVersionOverrides); + const images = resolveDbSetupImages(setup.serviceVersionOverrides, yield* slimImagesEnabled); return { jwks, images }; }); diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts index 521c67a6fe..61c9c7120e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts @@ -16,10 +16,12 @@ export function resolvePinnedImage( alias: string, localServiceName: LocalServiceVersionName, serviceVersions: LocalServiceVersionOverrides, + slim: boolean, ): string { return slimImageForCurrentPin( alias, dockerfileServiceImageRaw(alias), serviceVersions[localServiceName], + slim, ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts index 408f9adefa..bb03844234 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { toSlimImage } from "../../shared/services/slim-images.ts"; @@ -12,67 +12,62 @@ const currentPoolerTag = currentTag("supavisor"); const currentPostgres = dockerfileServiceImageRaw("pg"); const currentPostgresTag = currentTag("pg"); -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("resolvePinnedImage", () => { it("resolves docker.io images while the slim flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(resolvePinnedImage("gotrue", "auth", {})).toBe(currentAuth); - expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + expect(resolvePinnedImage("gotrue", "auth", {}, false)).toBe(currentAuth); + expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" }, false)).toBe( "supabase/gotrue:v2.100.0", ); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" }, false)).toBe( "supabase/supavisor:2.0.0", ); }); it("resolves slim images when the flag is on and the pin is current", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("gotrue", "auth", {})).toBe(toSlimImage("gotrue", currentAuth)); - expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag })).toBe( + expect(resolvePinnedImage("gotrue", "auth", {}, true)).toBe(toSlimImage("gotrue", currentAuth)); + expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag }, true)).toBe( toSlimImage("gotrue", currentAuth), ); }); it("keeps a historical pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" }, true)).toBe( "supabase/gotrue:v2.100.0", ); - expect(resolvePinnedImage("storage", "storage", { storage: "v1.67.0" })).toBe( + expect(resolvePinnedImage("storage", "storage", { storage: "v1.67.0" }, true)).toBe( "supabase/storage-api:v1.67.0", ); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" }, true)).toBe( "supabase/supavisor:2.0.0", ); }); it("normalizes a current pooler pin onto the slim tag scheme", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag }, true)).toBe( toSlimImage("supavisor", currentPooler), ); expect( - resolvePinnedImage("supavisor", "pooler", { - pooler: currentPoolerTag.startsWith("v") - ? currentPoolerTag.slice(1) - : `v${currentPoolerTag}`, - }), + resolvePinnedImage( + "supavisor", + "pooler", + { + pooler: currentPoolerTag.startsWith("v") + ? currentPoolerTag.slice(1) + : `v${currentPoolerTag}`, + }, + true, + ), ).toBe(toSlimImage("supavisor", currentPooler)); }); it("keeps a historical postgres pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" }, false)).toBe( "supabase/postgres:17.4.1.1", ); - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" }, true)).toBe( "supabase/postgres:17.4.1.1", ); - expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag }, true)).toBe( toSlimImage("pg", currentPostgres), ); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index e581d87702..7945658358 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -48,6 +48,7 @@ import { } from "./pgdata-snapshot.ts"; import type { PgDataArchiveProblem, PgDataSnapshotUnavailable } from "./pgdata-snapshot.ts"; import { resolvePinnedImage } from "./pinned-image.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { createShadowDatabase, removeShadowDatabase, @@ -290,13 +291,14 @@ const resolveShadowCacheKeyInputs = ( const realtimeConsumesJwks = input.setup.majorVersion >= 15 && input.setup.config.realtime.enabled; const jwks = realtimeConsumesJwks ? yield* input.setup.jwks : ""; + const slim = yield* slimImagesEnabled; const realtimeImage = yield* resolveJobImage( - resolvePinnedImage("realtime", "realtime", overrides), + resolvePinnedImage("realtime", "realtime", overrides, slim), ); const storageImage = yield* resolveJobImage( - resolvePinnedImage("storage", "storage", overrides), + resolvePinnedImage("storage", "storage", overrides, slim), ); - const authImage = yield* resolveJobImage(resolvePinnedImage("gotrue", "auth", overrides)); + const authImage = yield* resolveJobImage(resolvePinnedImage("gotrue", "auth", overrides, slim)); if (Option.isNone(realtimeImage) || Option.isNone(storageImage) || Option.isNone(authImage)) { return Option.none(); } diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index 072b8b86b0..cee9e8b932 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -1,7 +1,7 @@ import { Effect, type FileSystem, type Path } from "effect"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../shared/services/services.shared.ts"; -import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; +import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; /** * Resolves the local Postgres Docker image for commands that run a pg_dump/shadow-DB container @@ -73,7 +73,8 @@ export const resolveDbImage = Effect.fnUntraced(function* ( : `supabase/postgres:orioledb-${orioledbVersion}`; return { image, configImage: image }; } - const currentRaw = postgresImageForDbMajorVersion(majorVersion) ?? pgImageRaw(); + const slim = yield* slimImagesEnabled; + const currentRaw = postgresImageForDbMajorVersion(majorVersion, slim) ?? pgImageRaw(); let appliedPin: string | undefined; if (majorVersion > 14) { const versionPath = path.join(workdir, "supabase", ".temp", "postgres-version"); @@ -96,7 +97,7 @@ export const resolveDbImage = Effect.fnUntraced(function* ( const configImage = appliedPin !== undefined ? replaceImageTag(currentRaw, appliedPin) : currentRaw; return { - image: slimImageForCurrentPin("pg", currentRaw, appliedPin), + image: slimImageForCurrentPin("pg", currentRaw, appliedPin, slim), configImage, }; }); diff --git a/apps/cli/src/command-internal/db-image.unit.test.ts b/apps/cli/src/command-internal/db-image.unit.test.ts index 72946b7efa..6819ae401b 100644 --- a/apps/cli/src/command-internal/db-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-image.unit.test.ts @@ -61,7 +61,7 @@ describe("resolveDbImage", () => { configImage: POSTGRES_FALLBACK_IMAGE_PG15, }); expect(yield* resolve(dir, 17)).toEqual({ - image: dockerfileServiceImage("pg"), + image: dockerfileServiceImage("pg", false), configImage: currentPostgres, }); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/command-internal/edge-runtime-image.ts b/apps/cli/src/command-internal/edge-runtime-image.ts index 3f602bdc0b..2f1c29d222 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.ts @@ -4,7 +4,7 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; +import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; /** * Resolves the edge-runtime Docker image: the default tag comes from the Dockerfile image, a @@ -12,9 +12,10 @@ import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; * `edge_runtime.deno_version = 1` selects the legacy `deno1` image instead. */ -// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is -// observed by the resolver (and by tests that stub the env). -export const edgeRuntimeDockerfileImage = () => dockerfileServiceImage("edgeruntime"); +// Read per run, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is observed. +export const edgeRuntimeDockerfileImage = Effect.map(slimImagesEnabled, (slim) => + dockerfileServiceImage("edgeruntime", slim), +); // Used when `deno_version = 1`. No slim build exists for it, so it stays on docker.io regardless // of the flag — the same exception `edgeRuntimeImage` (`shared/functions/functions.shared.ts`) // applies for the functions Docker paths reading the same pin file. @@ -44,5 +45,10 @@ export const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( if (pinned === DENO1_EDGE_RUNTIME_VERSION) { return EDGE_RUNTIME_DENO1_IMAGE; } - return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); + return slimImageForCurrentPin( + "edgeruntime", + raw, + pinned.length > 0 ? pinned : undefined, + yield* slimImagesEnabled, + ); }); diff --git a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts index 458531b686..e791bb63e7 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts @@ -10,7 +10,7 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../shared/services/slim-images.ts"; +import { slimImagesEnabled, toSlimImage } from "../shared/services/slim-images.ts"; import { resolveEdgeRuntimeImage } from "./edge-runtime-image.ts"; const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); @@ -26,10 +26,10 @@ const resolve = (workdir: string, denoVersion: number) => describe("resolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "edge-img-")); - return resolve(dir, 2).pipe( - Effect.tap((image) => + return Effect.zip(resolve(dir, 2), slimImagesEnabled).pipe( + Effect.tap(([image, slim]) => Effect.sync(() => { - expect(image).toBe(dockerfileServiceImage("edgeruntime")); + expect(image).toBe(dockerfileServiceImage("edgeruntime", slim)); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 7f6badf1c4..d3a6ff0c3a 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2096,7 +2096,7 @@ describe("db diff", () => { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); expect(s.differCalls).toHaveLength(1); const call = s.differCalls[0] as DockerRunOpts; - expect(call.image).toBe(dockerfileServiceImage("differ")); + expect(call.image).toBe(dockerfileServiceImage("differ", false)); expect(call.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); expect(call.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); expect(call.env).toEqual({}); diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.ts index 4fc272ca5c..35b3e23dde 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.ts @@ -22,7 +22,8 @@ import { trimGoSpace } from "../shared/go-string.ts"; import { INTERNAL_SCHEMAS } from "../../../command-internal/pg-dump.env.ts"; import { DbDiffPgAdminError } from "./diff.errors.ts"; -const DIFFER_IMAGE = dockerfileServiceImage("differ"); +// `differ` has no slim build, so the slim flag never applies to it. +const DIFFER_IMAGE = dockerfileServiceImage("differ", false); /** * Only the front of the buffer is trimmed, not every occurrence — a real pgAdmin4 diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 0c00600613..7c9b82c61b 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { Config, ConfigProvider, @@ -596,7 +597,7 @@ describe("functions download", () => { // The unbundle tail is always the last 6 args regardless of whether // `--add-host` (Linux-only) was inserted before it. expect(runCommand?.args.slice(-6)).toEqual([ - `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, + `public.ecr.aws/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, "unbundle", "--eszip", "/root/eszips/output_hello-world.eszip", @@ -2044,7 +2045,7 @@ describe("functions download", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, ); expect( child.spawned.filter( @@ -2123,7 +2124,7 @@ describe("functions download", () => { ).toHaveLength(2); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 7c176ed579..275063814f 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -50,6 +50,7 @@ import { } from "../../../shared/runtime/process-control.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { @@ -1031,7 +1032,7 @@ describe("functions serve integration", () => { } expect(dockerRun.args).toContain( - yield* getRegistryImageUrl(dockerfileServiceImage("edgeruntime")), + yield* getRegistryImageUrl(dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)), ); expect(dockerRun.args.join(" ")).not.toContain(multilineValue); expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 0eec083ffe..505f1b65fd 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -9,6 +9,7 @@ import { withTempHome, } from "../../../../tests/helpers/cli.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { RESOLVE_BUDGET_MS, @@ -21,7 +22,6 @@ const TYPEGEN_LANGS: ReadonlyArray = languages .map((language) => language.name); type TypegenLang = string; -const LOCAL_POSTGRES_IMAGE = dockerfileServiceImage("pg"); const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; const TYPEGEN_TIMEOUT_MS = 90_000; // Image resolution runs inside the test body, so its timeout must add on top of the @@ -214,8 +214,9 @@ const startLocalPostgres = Effect.fnUntraced(function* (input: { }) { const containerName = localDbContainerId(input.projectId); const imageDeadline = resolveDeadline(LOCAL_IMAGE_BUDGET_MS); + const localPostgresImage = dockerfileServiceImage("pg", yield* slimImagesEnabled); const postgresImage = yield* Effect.tryPromise({ - try: () => ensureImage(LOCAL_POSTGRES_IMAGE, imageDeadline - RESOLVE_BUDGET_MS), + try: () => ensureImage(localPostgresImage, imageDeadline - RESOLVE_BUDGET_MS), catch: (cause) => new TypegenE2eSetupError({ message: "failed to ensure Docker image", cause }), }); diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 31f99f4aa5..74ea9d1ea6 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -36,6 +36,7 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -750,7 +751,9 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); - const expectedImage = yield* getRegistryImageUrl(dockerfileServiceImage("pg")); + const expectedImage = yield* getRegistryImageUrl( + dockerfileServiceImage("pg", yield* slimImagesEnabled), + ); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { expect(call.env["PGPORT"]).toBe("54320"); diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index dbbb151ed6..4cee002ec2 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -33,6 +33,7 @@ import { renderServicesWarning, type ServiceVersionRow, } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import type { ServicesFlags } from "./services.command.ts"; import { ServicesEnvNotSupportedError } from "./services.errors.ts"; import { stackServiceVersions } from "./services-local-stack.ts"; @@ -176,6 +177,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) imageOverrides["edge-runtime"] = edgeRuntimeImage; } const localImageOptions = { + slim: yield* slimImagesEnabled, imageOverrides, normalizeVersionTags: false, serviceVersions, diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index c22e86f41a..10970a06e0 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -33,6 +33,7 @@ import { import { mockTelemetryStateTracked, useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; @@ -188,8 +189,8 @@ const writeTempFile = Effect.fnUntraced(function* (workdir: string, name: string yield* fs.writeFileString(path.join(tempDir, name), content); }); -function postgresVersionForDbMajorVersion(majorVersion: number): string { - const image = postgresImageForDbMajorVersion(majorVersion); +function postgresVersionForDbMajorVersion(majorVersion: number, slim: boolean): string { + const image = postgresImageForDbMajorVersion(majorVersion, slim); if (image === undefined) { throw new Error(`Missing Postgres image for db major ${majorVersion}.`); } @@ -316,7 +317,7 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -417,7 +418,7 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -444,7 +445,7 @@ major_version = 15 expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -562,7 +563,7 @@ major_version = 15 expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), remote: "17.6.1.200", }), ); diff --git a/apps/cli/src/commands/start/services/gotrue.service.ts b/apps/cli/src/commands/start/services/gotrue.service.ts index 8de0ff43f1..dce09c4b3a 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.ts @@ -553,6 +553,8 @@ export function buildGotrueEnv(input: BuildGotrueEnvInput): Record { - vi.unstubAllEnvs(); -}); - // Every field not asserted by a specific subtest below reflects the // default config's own values. const baseEnvInput: BuildGotrueEnvInput = { @@ -674,6 +670,7 @@ describe("buildGotrueEnv", () => { describe("buildGotrueContainerSpec", () => { test("assembles the full container spec, deriving dbHost/dbPassword from projectId/dbUrl", () => { const spec = buildGotrueContainerSpec({ + slim: false, image: "supabase/gotrue:v2.180.0", projectId: "proj", networkId: "supabase_network_proj", @@ -710,8 +707,8 @@ describe("buildGotrueContainerSpec", () => { }); test("uses BusyBox wget flags on a slim auth image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildGotrueContainerSpec({ + slim: true, image: "ghcr.io/supabase/cli/auth:v2.196.0", projectId: "proj", networkId: "supabase_network_proj", diff --git a/apps/cli/src/commands/start/services/logflare.service.ts b/apps/cli/src/commands/start/services/logflare.service.ts index 8ae024e704..a4b39aa6a7 100644 --- a/apps/cli/src/commands/start/services/logflare.service.ts +++ b/apps/cli/src/commands/start/services/logflare.service.ts @@ -49,6 +49,8 @@ const LOGFLARE_ENTRYPOINT_SCRIPT = "EOF\n"; export interface LogflareContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** * The already-resolved `config.analytics.image`. Not part of the decoded * `@supabase/config` schema; resolution is the caller's responsibility. @@ -111,7 +113,7 @@ export function buildLogflareContainerSpec( }; const binds: Array = []; - const slim = usesSlimImageRuntime(input.image); + const slim = usesSlimImageRuntime(input.image, input.slim); if (input.backend === "bigquery") { const hostJwtPath = path.join(input.workdir, input.gcpJwtPath); diff --git a/apps/cli/src/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/commands/start/services/logflare.service.unit.test.ts index dda73d1fad..2555d56798 100644 --- a/apps/cli/src/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/logflare.service.unit.test.ts @@ -2,15 +2,12 @@ import { BunPath } from "@effect/platform-bun"; import { Effect, Path } from "effect"; import { it } from "@effect/vitest"; -import { afterEach, describe, expect, vi } from "vitest"; +import { describe, expect } from "vitest"; import { buildLogflareContainerSpec, type LogflareContainerSpecInput } from "./logflare.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const base: LogflareContainerSpecInput = { + slim: false, image: "supabase/logflare:1.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -172,10 +169,10 @@ describe("buildLogflareContainerSpec", () => { () => { return Effect.gen(function* () { const path = yield* Path.Path; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildLogflareContainerSpec( { ...base, + slim: true, image: "ghcr.io/supabase/cli/analytics:v1.50.6", backend: "bigquery", gcpProjectId: "my-project", @@ -195,15 +192,15 @@ describe("buildLogflareContainerSpec", () => { it.effect("overrides the entrypoint and uses wget on a slim analytics image", () => { return Effect.gen(function* () { const path = yield* Path.Path; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const slim = buildLogflareContainerSpec( { ...base, + slim: true, image: "ghcr.io/supabase/cli/analytics:v1.50.6", }, path, ); - const dockerIo = buildLogflareContainerSpec(base, path); + const dockerIo = buildLogflareContainerSpec({ ...base, slim: true }, path); expect(slim.entrypoint).toBe(dockerIo.entrypoint); expect(slim.cmd).toEqual(dockerIo.cmd); expect(slim.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/realtime.service.ts b/apps/cli/src/commands/start/services/realtime.service.ts index 8dafa26358..ea7ca439c4 100644 --- a/apps/cli/src/commands/start/services/realtime.service.ts +++ b/apps/cli/src/commands/start/services/realtime.service.ts @@ -16,6 +16,8 @@ import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; import { startInternalDbPassword } from "../../../command-internal/db-bootstrap/internal-db-connection.ts"; export interface RealtimeContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The sanitized project id. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ @@ -51,7 +53,7 @@ export function buildRealtimeContainerSpec(input: RealtimeContainerSpecInput): S env, binds: [], exposedPorts: [{ containerPort: "4000" }], - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { header: `Host:${REALTIME_TENANT_ID}`, }) diff --git a/apps/cli/src/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/commands/start/services/realtime.service.unit.test.ts index f6b5812a48..4feba183ed 100644 --- a/apps/cli/src/commands/start/services/realtime.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/realtime.service.unit.test.ts @@ -1,13 +1,10 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildRealtimeContainerSpec, type RealtimeContainerSpecInput } from "./realtime.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("buildRealtimeContainerSpec", () => { const input: RealtimeContainerSpecInput = { + slim: false, projectId: "proj", networkId: "supabase_network_proj", image: "supabase/realtime:v2", @@ -68,9 +65,9 @@ describe("buildRealtimeContainerSpec", () => { }); test("uses wget for the healthcheck on a slim realtime image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildRealtimeContainerSpec({ ...input, + slim: true, image: "ghcr.io/supabase/cli/realtime:v2.130.0", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/storage.service.ts b/apps/cli/src/commands/start/services/storage.service.ts index 8a4e6ccf88..b38fe1edd5 100644 --- a/apps/cli/src/commands/start/services/storage.service.ts +++ b/apps/cli/src/commands/start/services/storage.service.ts @@ -135,6 +135,8 @@ export function buildStorageEnv(input: StorageEnvInput): Record } export interface StorageContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The sanitized project id. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ @@ -190,7 +192,7 @@ export function buildStorageContainerSpec(input: StorageContainerSpecInput): Sta env, binds: [`${containerName}:${STORAGE_DOCKER_PATH}`], // IPv4 loopback: localhost can resolve to IPv6 on GitPod and miss the listener. - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:5000/status") : { test: [ diff --git a/apps/cli/src/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/commands/start/services/storage.service.unit.test.ts index d2ab97f05a..7cd8bd0d35 100644 --- a/apps/cli/src/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/storage.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { appendStorageVectorEnv, @@ -8,10 +8,6 @@ import { type StorageEnvInput, } from "./storage.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const baseEnvInput: StorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -178,6 +174,7 @@ describe("appendStorageVectorEnv", () => { describe("buildStorageContainerSpec", () => { const input: StorageContainerSpecInput = { + slim: false, projectId: "proj", networkId: "supabase_network_proj", image: "supabase/storage-api:v1", @@ -230,9 +227,9 @@ describe("buildStorageContainerSpec", () => { }); test("uses BusyBox wget flags on a slim storage image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildStorageContainerSpec({ ...input, + slim: true, image: "ghcr.io/supabase/cli/storage:v1.72.1", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/supavisor.service.ts b/apps/cli/src/commands/start/services/supavisor.service.ts index dbf7f7d3f9..db2c8b317f 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.ts @@ -56,6 +56,8 @@ export function buildSupavisorStartCmd(): ReadonlyArray { } export interface SupavisorContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The already-resolved `config.db.pooler.image`; resolution is the caller's responsibility. */ readonly image: string; /** The project id, used to derive this container's own name via {@link serviceContainerName}. */ @@ -130,7 +132,7 @@ export function buildSupavisorContainerSpec( ], ports: [{ hostPort: String(input.port), containerPort: dockerPort }], // Slim pooler ships wget, not curl. - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:4000/api/health") : { test: [ diff --git a/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts b/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts index 26839a34ee..b6052078cd 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildSupavisorContainerSpec, @@ -6,11 +6,8 @@ import { type SupavisorContainerSpecInput, } from "./supavisor.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const base: SupavisorContainerSpecInput = { + slim: false, image: "supabase/supavisor:2.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -133,9 +130,9 @@ describe("buildSupavisorContainerSpec", () => { }); test("uses wget for the healthcheck on a slim pooler image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildSupavisorContainerSpec({ ...base, + slim: true, image: "ghcr.io/supabase/cli/pooler:v2.9.12", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index c82690c315..fac0f51757 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -246,6 +246,8 @@ export function buildVectorEntrypointScript( } export interface VectorContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** `config.analytics.vector_image`, already resolved/pulled by the caller. */ readonly image: string; /** `serviceContainerName("vector", projectId)`, also used as the `vector.yaml` template's `vectorId` field. */ @@ -276,7 +278,7 @@ export interface VectorContainerSpecInput { /** Builds Vector's {@link StartContainerSpec}. */ export function buildVectorContainerSpec(input: VectorContainerSpecInput): StartContainerSpec { - const slim = usesSlimImageRuntime(input.image); + const slim = usesSlimImageRuntime(input.image, input.slim); const vectorYaml = renderStartVectorYaml({ apiKey: input.apiKey, vectorId: input.containerName, diff --git a/apps/cli/src/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/commands/start/services/vector.service.unit.test.ts index 0f991a8666..c82f2bae8b 100644 --- a/apps/cli/src/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/vector.service.unit.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, test } from "@effect/vitest"; -import { afterEach, vi } from "vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -15,10 +14,6 @@ import { type VectorDockerSocketPlan, } from "./vector.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - /** Matches the standing `mockSpawner` shape in `image-prepull.unit.test.ts`. */ function mockSpawner( handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, @@ -223,6 +218,7 @@ describe("buildVectorEntrypointScript", () => { }); const base: VectorContainerSpecInput = { + slim: false, image: "supabase/vector:0.28.1", containerName: "supabase_vector_proj", networkId: "supabase_network_proj", @@ -287,9 +283,9 @@ describe("buildVectorContainerSpec", () => { }); test("slim image waits on Logflare with BusyBox wget flags", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildVectorContainerSpec({ ...base, + slim: true, image: "ghcr.io/supabase/cli/vector:0.53.0", }); expect(spec.entrypoint).toBe("sh"); diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index eda523cb2e..3c5b21aecd 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -212,6 +212,7 @@ export interface StartImagePlanEntry { */ export function resolveStartImagePlan( gates: StartGates, + slim: boolean, serviceVersions: LocalServiceVersionOverrides = {}, ): ReadonlyArray { const plan: Array = []; @@ -223,8 +224,8 @@ export function resolveStartImagePlan( const localServiceName = START_SERVICE_TO_LOCAL_VERSION_NAME[entry.service]; const image = localServiceName === undefined - ? dockerfileServiceImage(alias) - : resolvePinnedImage(alias, localServiceName, serviceVersions); + ? dockerfileServiceImage(alias, slim) + : resolvePinnedImage(alias, localServiceName, serviceVersions, slim); plan.push({ service: entry.service, image }); } return plan; diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 5361dd9270..6c2e52d394 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -134,6 +134,7 @@ import { START_WAITING_FOR_HEALTH_CHECKS_MESSAGE, } from "./start.format.ts"; import { resolveStartGates, resolveStartImagePlan } from "./start.gates.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { isUnhealthyStartError, rollbackStart, @@ -755,7 +756,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // 7. Resolve every image that will actually be pulled before any container is created. - const imagePlan = resolveStartImagePlan(gates, serviceVersionOverrides); + const slim = yield* slimImagesEnabled; + const imagePlan = resolveStartImagePlan(gates, slim, serviceVersionOverrides); // Edge Runtime doesn't go through `resolveStartImagePlan` (see `start.gates.ts`'s header), // so its default image is resolved independently, pre-pulled when enabled and not excluded. const edgeRuntimeDefaultImage = gates.edgeRuntime @@ -1099,6 +1101,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { spec: buildLogflareContainerSpec( { image, + slim, projectId, networkId, port: analyticsPort, @@ -1131,6 +1134,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildVectorContainerSpec({ image, + slim, containerName: vectorContainerName, networkId, apiKey: ANALYTICS_API_KEY, @@ -1189,6 +1193,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildGotrueContainerSpec({ image, + slim, projectId, networkId, dbUrl: values.dbUrl, @@ -1221,6 +1226,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, image, + slim, ipVersion: realtimeIpVersion, maxHeaderLength: realtimeMaxHeaderLength, dbUrl: values.dbUrl, @@ -1249,6 +1255,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, image, + slim, targetMigration: storageTargetMigration, fileSizeLimit: storageFileSizeLimit, s3Region: values.storageS3Region, @@ -1334,6 +1341,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildSupavisorContainerSpec({ image, + slim, projectId, networkId, port: poolerPort, diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index 179a7e3ae1..c7429d744b 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -17,6 +17,7 @@ import { import { getRegistryImageUrl } from "../../command-internal/docker-registry.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; class StartE2eSetupError extends Data.TaggedError("StartE2eSetupError")<{ readonly message: string; @@ -286,7 +287,9 @@ describe("supabase start (e2e)", () => { const mailpitContainer = serviceContainerName("inbucket", projectId); // The exact tag `start` resolves for Mailpit, so its already-cached check finds this // broken build without reaching a registry. - const mailpitImage = yield* getRegistryImageUrl(dockerfileServiceImage("mailpit")); + const mailpitImage = yield* getRegistryImageUrl( + dockerfileServiceImage("mailpit", yield* slimImagesEnabled), + ); const init = yield* runSupabaseEffect(["init"], { cwd: projectDir, diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index 73f6921233..8443a5eecd 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -1,6 +1,6 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; @@ -216,10 +216,6 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => }); describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - const allGatesOpen: StartGates = { kong: true, gotrue: true, @@ -236,26 +232,29 @@ describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { edgeRuntime: true, }; - const imageFor = (service: string, serviceVersions: LocalServiceVersionOverrides = {}) => - resolveStartImagePlan(allGatesOpen, serviceVersions).find((entry) => entry.service === service) - ?.image; + const imageFor = ( + service: string, + slim: boolean, + serviceVersions: LocalServiceVersionOverrides = {}, + ) => + resolveStartImagePlan(allGatesOpen, slim, serviceVersions).find( + (entry) => entry.service === service, + )?.image; it("plans docker.io images while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - expect(imageFor("gotrue")).toBe(currentGotrue); - expect(imageFor("vector")).toBe(currentVector); - expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + expect(imageFor("gotrue", false)).toBe(currentGotrue); + expect(imageFor("vector", false)).toBe(currentVector); + expect(imageFor("supavisor", false, { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); }); it("plans slim images when the flag is on, keeping unmapped services on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(imageFor("gotrue")).toBe(toSlimImage("gotrue", currentGotrue)); - expect(imageFor("logflare")).toBe(toSlimImage("logflare", currentLogflare)); - expect(imageFor("vector")).toBe(toSlimImage("vector", currentVector)); - expect(imageFor("supavisor", { pooler: currentPoolerTag })).toBe( + expect(imageFor("gotrue", true)).toBe(toSlimImage("gotrue", currentGotrue)); + expect(imageFor("logflare", true)).toBe(toSlimImage("logflare", currentLogflare)); + expect(imageFor("vector", true)).toBe(toSlimImage("vector", currentVector)); + expect(imageFor("supavisor", true, { pooler: currentPoolerTag })).toBe( toSlimImage("supavisor", currentPooler), ); - expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); - expect(imageFor("kong")).toBe("library/kong:2.8.1"); + expect(imageFor("supavisor", true, { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + expect(imageFor("kong", true)).toBe("library/kong:2.8.1"); }); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index a16778e794..ed0c55277b 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Fiber } from "effect"; +import { Deferred, Effect, Exit, Fiber, Schema } from "effect"; import { feedbackClientLayer } from "./feedback-client.layer.ts"; import type { FeedbackSubmission } from "./feedback-client.service.ts"; import { FeedbackClient } from "./feedback-client.service.ts"; @@ -39,11 +39,16 @@ function recordingFetch( ) { const requests: Array<{ request: Request; bodyText: string }> = []; const fetch: typeof globalThis.fetch = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit): Promise => { const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init); - requests.push({ request, bodyText: await request.clone().text() }); - return respond(request); + return request + .clone() + .text() + .then((bodyText) => { + requests.push({ request, bodyText }); + return respond(request); + }); }, { preconnect: () => Promise.resolve() }, ); @@ -57,6 +62,10 @@ function jsonResponse(body: unknown, status = 200) { }); } +const decodeJsonBody = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + function layerWith(transport: ReturnType) { return feedbackClientLayer({ environment: TEST_ENV, fetch: transport.fetch }); } @@ -75,7 +84,7 @@ describe("feedbackClientLayer", () => { expect(request.method).toBe("POST"); expect(request.url).toBe(`${TEST_ENV.url}/rest/v1/rpc/submit_interfaces_feedback`); expect(request.headers.get("apikey")).toBe(TEST_ENV.key); - expect(JSON.parse(bodyText)).toEqual({ + expect(yield* decodeJsonBody(bodyText)).toEqual({ feedback: "port conflicts when running two stacks", user_agent: "SupabaseCLI/9.9.9", project_ref: PROJECT_REF, @@ -107,12 +116,12 @@ describe("feedbackClientLayer", () => { }, }); - const body = JSON.parse(transport.requests[0]!.bodyText); + const body = yield* decodeJsonBody(transport.requests[0]!.bodyText); // The RPC's `project_ref` parameter defaults to null server-side; the // CLI simply leaves it (and `user_id`) out of the call. expect(body).not.toHaveProperty("project_ref"); expect(body).not.toHaveProperty("user_id"); - expect(body.metadata).toEqual({ + expect(body["metadata"]).toEqual({ cli_version: "9.9.9", source: "cli", os: "linux", @@ -172,20 +181,18 @@ describe("feedbackClientLayer", () => { // insert commit after the command was cancelled. The fake fetch behaves // like a real one: it settles only when its abort signal fires. let capturedSignal: AbortSignal | undefined; - const inFlight = Promise.withResolvers(); + const inFlight = Deferred.makeUnsafe(); const transport = recordingFetch((request) => { capturedSignal = request.signal; - inFlight.resolve(); - return new Promise((_, reject) => { - request.signal.addEventListener("abort", () => reject(request.signal.reason)); - }); + Deferred.doneUnsafe(inFlight, Exit.void); + return Effect.runPromise(Effect.never, { signal: request.signal }); }); return Effect.gen(function* () { const client = yield* FeedbackClient; const fiber = yield* client .submit(SUBMISSION) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => inFlight.promise); + yield* Deferred.await(inFlight); yield* Fiber.interrupt(fiber); expect(capturedSignal?.aborted).toBe(true); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 53104dbaaf..2282b7b6ef 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -34,6 +34,7 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage, } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDeployFlagsError, FunctionDeployCancelledError, @@ -1457,7 +1458,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( const outputPath = join(outputDir, "output.eszip"); // `edgeRuntimeImage` applies the tag verbatim — a `.temp/edge-runtime-version` pin flows // through unmodified, `v` prefix or not (see the helper's doc in `functions.shared.ts`). - const rawImage = edgeRuntimeImage(edgeRuntimeVersion); + const rawImage = edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled); const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { bitbucketCloneDirDefined, onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 82aaf569f5..46f8bce592 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -35,6 +35,7 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage, } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDownloadFlagsError, FunctionDownloadNotFoundError, @@ -899,7 +900,7 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( // `edgeRuntimeImage` applies the tag verbatim; a `.temp/edge-runtime-version` // pin flows through unmodified. Registry mapping + pull-with-retry happens // per-container, right before `ensureDockerNetwork` (see the caller). - rawImage: edgeRuntimeImage(edgeRuntimeVersion), + rawImage: edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled), projectEnvValues: context.projectEnvValues, }; }); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 0c6ebabb3c..2f691ac4ff 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -32,11 +32,11 @@ export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; * is substituted verbatim into the (possibly slim-rewritten) default image, * with no `v` prefix synthesized. */ -export function edgeRuntimeImage(tag: string): string { +export function edgeRuntimeImage(tag: string, slim: boolean): string { if (tag === DENO1_EDGE_RUNTIME_VERSION) { return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; } - return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag); + return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag, slim); } /** diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts index 5eb8806232..64e5a3af49 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -17,22 +17,19 @@ afterEach(() => { describe("edgeRuntimeImage", () => { it("keeps the deno1 tag on the docker.io image even when the slim flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION, true)).toBe( `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, ); }); it("rewrites the current Dockerfile tag onto the slim ghcr.io image when the flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(edgeRuntimeImage(currentEdgeRuntimeTag)).toBe( + expect(edgeRuntimeImage(currentEdgeRuntimeTag, true)).toBe( `ghcr.io/supabase/cli/edge-runtime:${currentEdgeRuntimeTag}`, ); }); it("keeps a historical pin on docker.io when the flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(edgeRuntimeImage("v1.73.0")).toBe("supabase/edge-runtime:v1.73.0"); + expect(edgeRuntimeImage("v1.73.0", true)).toBe("supabase/edge-runtime:v1.73.0"); }); }); diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index 39ea81a62e..b83d3ae5e0 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import { Effect } from "effect"; import { describe, expect, test } from "vitest"; import { START_KONG_YML_TEMPLATE } from "../../commands/start/templates/kong.yml.ts"; @@ -274,7 +275,7 @@ describe("functions serve runtime template (offline)", () => { "boots under edge-runtime with networking disabled and fetches nothing remote", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(edgeRuntimeDockerfileImage()); + const runtimeImage = await ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage)); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { @@ -336,7 +337,7 @@ describe("functions serve runtime template (offline)", () => { "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(edgeRuntimeDockerfileImage()); + const runtimeImage = await ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage)); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { @@ -431,8 +432,8 @@ describe("functions serve runtime template (offline)", () => { async () => { const imageDeadline = resolveDeadline(); const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(edgeRuntimeDockerfileImage(), imageDeadline), - ensureImage(dockerfileServiceImage("kong"), imageDeadline), + ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage), imageDeadline), + ensureImage(dockerfileServiceImage("kong", false), imageDeadline), ]); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); const network = `supabase-serve-kong-e2e-${process.pid.toString()}`; diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 78d0ecf405..e55ba8ddec 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -89,6 +89,7 @@ import { } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, @@ -2031,7 +2032,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // `docker pull` on cold cache instead of immediately — left open since // fixing it risks `start`'s shared, more critical bring-up path. const image = yield* resolveFunctionsDockerImage( - edgeRuntimeImage(edgeRuntimeVersion), + edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled), resolved.projectEnvValues, ); diff --git a/apps/cli/src/shared/init/project-init.modes.integration.test.ts b/apps/cli/src/shared/init/project-init.modes.integration.test.ts index 58ac00eb09..b89df561fd 100644 --- a/apps/cli/src/shared/init/project-init.modes.integration.test.ts +++ b/apps/cli/src/shared/init/project-init.modes.integration.test.ts @@ -1,17 +1,21 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../tests/helpers/mocks.ts"; import { initProject } from "./project-init.ts"; -function makeTempProjectDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-init-modes-")); -} +const makeTempProjectDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-init-modes-" }), +); + +// Pin the process umask to 0 to prove the modes below are pinned explicitly, +// not incidental to Node's own umask-masked defaults (which coincide under +// the common 022). +const zeroUmask = Effect.acquireRelease( + Effect.sync(() => process.umask(0)), + (prevUmask) => Effect.sync(() => process.umask(prevUmask)), +); function runInit(cwd: string) { const out = mockOutput({ format: "text", interactive: false }); @@ -29,54 +33,39 @@ function runInit(cwd: string) { }).pipe(Effect.provide(layer)); } -// Pin the process umask to 0 to prove the modes below are pinned explicitly, -// not incidental to Node's own umask-masked defaults (which coincide under -// the common 022). +const fileMode = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.stat(pathname)).mode & 0o777; +}); + describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { - it.live("pins the supabase dir and config.toml to Go's exact modes", () => { - const cwd = makeTempProjectDir(); - const prevUmask = process.umask(0); + it.live("pins the supabase dir and config.toml to Go's exact modes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const cwd = yield* makeTempProjectDir; + yield* zeroUmask; - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const supabaseDir = join(cwd, "supabase"); - const configTomlPath = join(supabaseDir, "config.toml"); + yield* runInit(cwd); - expect(statSync(supabaseDir).mode & 0o777).toBe(0o755); - expect(statSync(configTomlPath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); - }); + const supabaseDir = path.join(cwd, "supabase"); + expect(yield* fileMode(supabaseDir)).toBe(0o755); + expect(yield* fileMode(path.join(supabaseDir, "config.toml"))).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), + ); it.live( "pins a freshly created supabase/.gitignore to Go's exact file mode inside a git repo", - () => { - const cwd = makeTempProjectDir(); - mkdirSync(join(cwd, ".git")); - const prevUmask = process.umask(0); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempProjectDir; + yield* fs.makeDirectory(path.join(cwd, ".git")); + yield* zeroUmask; + + yield* runInit(cwd); - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const gitignorePath = join(cwd, "supabase", ".gitignore"); - expect(statSync(gitignorePath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); - }, + expect(yield* fileMode(path.join(cwd, "supabase", ".gitignore"))).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 744556105f..fefb42dbd3 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { applyConfigEdits, type ConfigEdit } from "@supabase/config/internal"; import { INIT_GITIGNORE_TEMPLATE, @@ -11,18 +10,20 @@ import { renderCliConfigTemplate, } from "./project-init.templates.ts"; -const here = dirname(fileURLToPath(import.meta.url)); -// Vendored copies of the Go CLI's init-template scaffold files. Dotted file -// names are de-dotted so git/tooling don't interpret the fixtures themselves. -const goTemplatesFixtureDir = join(here, "testdata/go-templates"); - function normalizeNewlines(text: string): string { return text.replace(/\r\n/g, "\n"); } -function readVendoredTemplate(name: string): string { - return normalizeNewlines(readFileSync(join(goTemplatesFixtureDir, name), "utf8")); -} +// Vendored copies of the Go CLI's init-template scaffold files. Dotted file +// names are de-dotted so git/tooling don't interpret the fixtures themselves. +const readVendoredTemplate = Effect.fnUntraced(function* (name: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const contents = yield* fs.readFileString( + path.join(import.meta.dirname, "testdata/go-templates", name), + ); + return normalizeNewlines(contents); +}); // Go's config.toml scaffold renders through text/template (`config.Eject`), so // an action wrapping a backtick raw string — {{ `{{ .Code }}` }} in the @@ -34,16 +35,16 @@ function resolveGoTemplateEscapes(template: string): string { } // Emulates what Go's config.Eject writes to disk for a fresh `supabase init` project. -function renderExpectedGoEject(): string { - return ( - resolveGoTemplateEscapes(readVendoredTemplate("config.toml")) +const renderExpectedGoEject = readVendoredTemplate("config.toml").pipe( + Effect.map((template) => + resolveGoTemplateEscapes(template) .replace("{{ .ProjectId }}", "demo-project") .replace("{{ .Db.OrioleDBVersion }}", "17.11.0.002") // supabase init always opts new projects into pg-delta; the Go template // renders this from a flag only set on the init path. - .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") - ); -} + .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true"), + ), +); // The Go scaffold still describes `auto_expose_new_tables` as unset-means- // revoked and deprecated; the native template documents unset-means-exposed @@ -57,29 +58,37 @@ const NATIVE_AUTO_EXPOSE_COMMENT = `# without explicit GRANTs, matching the clou # instead. Left unset, a fresh project falls back to \`true\`. # auto_expose_new_tables = true`; -function renderExpectedNativeEject(): string { - return renderExpectedGoEject() - .replace( - '# content_path = "./templates/password_changed_notification.html"', - '# content_path = "./supabase/templates/password_changed_notification.html"', - ) - .replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT); -} +const renderExpectedNativeEject = renderExpectedGoEject.pipe( + Effect.map((eject) => + eject + .replace( + '# content_path = "./templates/password_changed_notification.html"', + '# content_path = "./supabase/templates/password_changed_notification.html"', + ) + .replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT), + ), +); describe("project init templates", () => { - it("renders config.toml with the native notification content_path base", () => { - expect(normalizeNewlines(renderCliConfigTemplate("demo-project", true))).toBe( - renderExpectedNativeEject(), - ); - }); + it.effect("renders config.toml with the native notification content_path base", () => + Effect.gen(function* () { + expect(normalizeNewlines(renderCliConfigTemplate("demo-project", true))).toBe( + yield* renderExpectedNativeEject, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("models every template action in the Go scaffold, so parity cannot silently drift", () => { - // Anything beyond the GoTrue OTP placeholder means the Go template gained - // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` - // to match before shipping. - const unresolvedActions = renderExpectedGoEject().match(/\{\{[^}]*\}\}/g) ?? []; - expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); - }); + it.effect( + "models every template action in the Go scaffold, so parity cannot silently drift", + () => + Effect.gen(function* () { + // Anything beyond the GoTrue OTP placeholder means the Go template gained + // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` + // to match before shipping. + const unresolvedActions = (yield* renderExpectedGoEject).match(/\{\{[^}]*\}\}/g) ?? []; + expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); + }).pipe(Effect.provide(BunServices.layer)), + ); it("renders the SMS and MFA phone OTP templates as GoTrue templates, not raw Go escapes", () => { const rendered = renderCliConfigTemplate("demo-project", false); @@ -112,21 +121,33 @@ describe("project init templates", () => { expect(rendered).not.toMatch(/^port = 54327$/m); }); - it("matches the Go .gitignore scaffold", () => { - expect(INIT_GITIGNORE_TEMPLATE).toBe(readVendoredTemplate("gitignore")); - }); + it.effect("matches the Go .gitignore scaffold", () => + Effect.gen(function* () { + expect(INIT_GITIGNORE_TEMPLATE).toBe(yield* readVendoredTemplate("gitignore")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code extensions scaffold", () => { - expect(VSCODE_EXTENSIONS_TEMPLATE).toBe(readVendoredTemplate("vscode-extensions.json.golden")); - }); + it.effect("matches the Go VS Code extensions scaffold", () => + Effect.gen(function* () { + expect(VSCODE_EXTENSIONS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-extensions.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code settings scaffold", () => { - expect(VSCODE_SETTINGS_TEMPLATE).toBe(readVendoredTemplate("vscode-settings.json.golden")); - }); + it.effect("matches the Go VS Code settings scaffold", () => + Effect.gen(function* () { + expect(VSCODE_SETTINGS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-settings.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go IntelliJ scaffold", () => { - expect(INTELLIJ_DENO_TEMPLATE).toBe(readVendoredTemplate("idea-deno.xml")); - }); + it.effect("matches the Go IntelliJ scaffold", () => + Effect.gen(function* () { + expect(INTELLIJ_DENO_TEMPLATE).toBe(yield* readVendoredTemplate("idea-deno.xml")); + }).pipe(Effect.provide(BunServices.layer)), + ); }); // `applyConfigEdits` must edit the scaffold exactly as intended and nothing diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index a5d8e82b30..33675d242d 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -106,6 +106,8 @@ const decodeJsonObject = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); +const encodePrettyJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + // Parses a settings file through a Schema boundary so malformed JSON surfaces // as a typed `InitParseSettingsError` (never a fiber defect) and a // non-object document is rejected. @@ -147,9 +149,9 @@ const INIT_DIR_MODE = 0o755; function writeJsonFile(pathname: string, contents: Record) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`, { - mode: INIT_FILE_MODE, - }); + // `contents` was just decoded from JSON, so encoding it back cannot fail. + const json = yield* encodePrettyJson(contents).pipe(Effect.orDie); + yield* fs.writeFileString(pathname, `${json}\n`, { mode: INIT_FILE_MODE }); }); } diff --git a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts b/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts index d8d058c146..5471886e14 100644 --- a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts +++ b/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts @@ -1,18 +1,20 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; // The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed // yet; this keeps the two copies in sync until apps/cli-go is deleted, at which point // this test should be deleted alongside it. -const GO_DOCKERFILE_PATH = fileURLToPath( - new URL("../../../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), -); - describe("Go Dockerfile sync guard", () => { - test("keeps the Go tree's embedded Dockerfile byte-identical to the TS-owned copy", () => { - const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); - expect(goDockerfile).toBe(serviceImagesDockerfile); - }); + it.effect("keeps the Go tree's embedded Dockerfile byte-identical to the TS-owned copy", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const goDockerfile = yield* fs.readFileString( + path.resolve(import.meta.dirname, "../../../../cli-go/pkg/config/templates/Dockerfile"), + ); + expect(goDockerfile).toBe(serviceImagesDockerfile); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/services/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index 70ca17a9b2..377f78a034 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -25,6 +25,6 @@ export function dockerfileServiceImageRaw(alias: string): string { * point for default service images; use `dockerfileServiceImageRaw` where the * docker.io identity itself is the contract (user-facing short names). */ -export function dockerfileServiceImage(alias: string): string { - return slimImageForAlias(alias, dockerfileServiceImageRaw(alias)); +export function dockerfileServiceImage(alias: string, slim: boolean): string { + return slimImageForAlias(alias, dockerfileServiceImageRaw(alias), slim); } diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 1496757576..fb86318605 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -14,7 +14,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; -import { slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled } from "./slim-images.ts"; +import { slimImageForAlias, slimImageForCurrentPin } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -36,6 +36,8 @@ export type LocalServiceVersionOverrides = Partial>; export interface LocalServiceImageOptions { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; readonly imageOverrides?: LocalServiceImageOverrides; readonly normalizeVersionTags?: boolean; readonly serviceVersions?: LocalServiceVersionOverrides; @@ -120,11 +122,14 @@ export const POSTGRES_FALLBACK_IMAGE_PG15 = "supabase/postgres:15.8.1.085"; /** Published slim PG15 pin; flag-on majors 13/15 slim-translate this, not 15.8. */ export const POSTGRES_FALLBACK_IMAGE_PG15_SLIM = "supabase/postgres:15.14.1.167"; -export function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { +export function postgresImageForDbMajorVersion( + majorVersion: number, + slim: boolean, +): string | undefined { switch (majorVersion) { case 13: case 15: - return slimImagesEnabled() ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; + return slim ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; case 14: return POSTGRES_FALLBACK_IMAGE_PG14; default: @@ -166,14 +171,14 @@ export function isUsableServiceVersionTag( } function localServiceImagesForOptions( - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray { const normalizeVersionTags = options.normalizeVersionTags ?? true; - const slim = slimImagesEnabled(); + const slim = options.slim; return LOCAL_SERVICE_IMAGES.map((service) => { // Explicit overrides are used verbatim; the caller decides slim vs docker.io. const override = options.imageOverrides?.[service.localService]; - const baseImage = override ?? slimImageForAlias(service.alias, service.image); + const baseImage = override ?? slimImageForAlias(service.alias, service.image, slim); const version = options.serviceVersions?.[service.localService]; if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; @@ -185,8 +190,8 @@ function localServiceImagesForOptions( return { ...service, image: options.slimCurrentPinOnly - ? slimImageForCurrentPin(service.alias, service.image, pin) - : slimImageForAlias(service.alias, replaceImageTag(service.image, pin)), + ? slimImageForCurrentPin(service.alias, service.image, pin, slim) + : slimImageForAlias(service.alias, replaceImageTag(service.image, pin), slim), }; } return { @@ -364,7 +369,7 @@ const fetchPostgrestVersion = Effect.fnUntraced(function* ( const normalized = version?.trim().split(/\s+/)[0]; if (normalized === undefined || normalized.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "postgrest" })); + return yield* new ServiceVersionNotFoundError({ service: "postgrest" }); } return tagForServiceVersion("postgrest", normalized); @@ -379,7 +384,7 @@ const fetchAuthVersion = Effect.fnUntraced(function* ( const version = stringField(body, "version")?.trim(); if (version === undefined || version.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "auth" })); + return yield* new ServiceVersionNotFoundError({ service: "auth" }); } return version; @@ -392,15 +397,15 @@ const fetchStorageVersion = Effect.fnUntraced(function* ( ) { const version = (yield* fetchText(client, `${baseUrl}/storage/v1/version`, accessKey)).trim(); if (version.length === 0 || version === "0.0.0") { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "storage" })); + return yield* new ServiceVersionNotFoundError({ service: "storage" }); } return tagForServiceVersion("storage", version); }); -const fetchOptionalVersion = ( +const fetchOptionalVersion = ( service: OptionalRemoteServiceName, - effect: Effect.Effect, + effect: Effect.Effect, ) => effect.pipe( Effect.exit, @@ -420,14 +425,14 @@ const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetch }); export function listLocalServiceVersions( - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray { return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service)); } export function mergeRemoteServiceVersions( remote: Partial>, - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray { return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service, remote), diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index c22fb49b29..2471e2423c 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, it, test } from "@effect/vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; @@ -18,17 +18,15 @@ const PROJECT_REF = "abcdefghijklmnopqrst"; // `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead // of self-provisioning one, so each invocation needs a concrete transport. const runLinkedFetch = (input: Parameters[0]) => - Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); + fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); -describe("services shared", () => { - beforeEach(() => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); +const serve = (options: Parameters[0]) => + Effect.acquireRelease( + Effect.try(() => Bun.serve(options)), + (server) => Effect.promise(() => server.stop(true)), + ); +describe("services shared", () => { test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -51,7 +49,7 @@ describe("services shared", () => { }); test("derives local service versions from the Dockerfile manifest", () => { - const rows = listLocalServiceVersions(); + const rows = listLocalServiceVersions({ slim: false }); const dockerfileImages = localServiceImagesFromDockerfile(serviceImagesDockerfile); const expectedRows = dockerfileImages.map((service) => { const tagSeparator = service.image.lastIndexOf(":"); @@ -78,16 +76,14 @@ describe("services shared", () => { }); test("keeps the established PG13/15 fallback unless the slim flag is on", () => { - expect(postgresImageForDbMajorVersion(13)).toBe("supabase/postgres:15.8.1.085"); - expect(postgresImageForDbMajorVersion(15)).toBe("supabase/postgres:15.8.1.085"); - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(postgresImageForDbMajorVersion(13)).toBe("supabase/postgres:15.14.1.167"); - expect(postgresImageForDbMajorVersion(15)).toBe("supabase/postgres:15.14.1.167"); + expect(postgresImageForDbMajorVersion(13, false)).toBe("supabase/postgres:15.8.1.085"); + expect(postgresImageForDbMajorVersion(15, false)).toBe("supabase/postgres:15.8.1.085"); + expect(postgresImageForDbMajorVersion(13, true)).toBe("supabase/postgres:15.14.1.167"); + expect(postgresImageForDbMajorVersion(15, true)).toBe("supabase/postgres:15.14.1.167"); }); test("lists slim images when SUPABASE_USE_SLIM_IMAGES is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(listLocalServiceVersions().map((row) => row.name)).toEqual([ + expect(listLocalServiceVersions({ slim: true }).map((row) => row.name)).toEqual([ "ghcr.io/supabase/cli/postgres", "ghcr.io/supabase/cli/auth", "ghcr.io/supabase/cli/postgrest", @@ -102,9 +98,9 @@ describe("services shared", () => { }); test("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( listLocalServiceVersions({ + slim: true, slimCurrentPinOnly: true, serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" }, }), @@ -117,9 +113,9 @@ describe("services shared", () => { }); test("normalizes historical pins before slimCurrentPinOnly", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( listLocalServiceVersions({ + slim: true, slimCurrentPinOnly: true, serviceVersions: { auth: "2.151.0" }, }), @@ -127,8 +123,9 @@ describe("services shared", () => { }); test("slim-translates catalog version overrides that are not the Dockerfile pin", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(listLocalServiceVersions({ serviceVersions: { storage: "v1.70.3" } })).toContainEqual({ + expect( + listLocalServiceVersions({ slim: true, serviceVersions: { storage: "v1.70.3" } }), + ).toContainEqual({ name: "ghcr.io/supabase/cli/storage", local: "v1.70.3", remote: "", @@ -137,8 +134,8 @@ describe("services shared", () => { // Explicit overrides keep their registry; a serviceVersions pin still rewrites the tag. test("leaves explicit image overrides on docker.io when SUPABASE_USE_SLIM_IMAGES is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const rows = listLocalServiceVersions({ + slim: true, imageOverrides: { postgres: "supabase/postgres:15.8.1.085", "edge-runtime": "supabase/edge-runtime:v1.68.4", @@ -158,6 +155,7 @@ describe("services shared", () => { test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ + slim: false, normalizeVersionTags: false, serviceVersions: { auth: "2.151.0", @@ -171,58 +169,58 @@ describe("services shared", () => { ); }); - test("returns postgres only when no service-role key is available", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return Response.json({ - id: PROJECT_REF, - ref: PROJECT_REF, - organization_id: "org-id", - organization_slug: "org", - name: "Linked Project", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.6.1.200", - postgres_engine: "17", - release_channel: "ga", - }, - }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "anon", - id: "publishable-id", - type: "publishable", - api_key: "publishable-key", - description: null, - }, - ]); - } - - if ( - url.pathname === "/auth/v1/health" || - url.pathname === "/rest/v1/" || - url.pathname === "/storage/v1/version" - ) { - throw new Error( - `tenant endpoint should not be called without a service-role key: ${url.pathname}`, - ); - } - - return new Response("not found", { status: 404 }); - }, - }); + it.live("returns postgres only when no service-role key is available", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + if ( + url.pathname === "/auth/v1/health" || + url.pathname === "/rest/v1/" || + url.pathname === "/storage/v1/version" + ) { + throw new Error( + `tenant endpoint should not be called without a service-role key: ${url.pathname}`, + ); + } - try { - const result = await runLinkedFetch({ + return new Response("not found", { status: 404 }); + }, + }); + + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -232,45 +230,43 @@ describe("services shared", () => { }); expect(result).toEqual({ postgres: "17.6.1.200" }); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("returns no linked versions when project api keys cannot be loaded", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } - test("returns no linked versions when project api keys cannot be loaded", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return Response.json({ - id: PROJECT_REF, - ref: PROJECT_REF, - organization_id: "org-id", - organization_slug: "org", - name: "Linked Project", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.6.1.200", - postgres_engine: "17", - release_channel: "ga", - }, - }); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -280,51 +276,49 @@ describe("services shared", () => { }); expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("still returns tenant service versions when project version lookup fails", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("1.61.0"); + } - test("still returns tenant service versions when project version lookup fails", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "service-role-key", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === "/auth/v1/health") { - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/") { - return Response.json({ info: { version: "14.13" } }); - } - - if (url.pathname === "/storage/v1/version") { - return new Response("1.61.0"); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -338,51 +332,49 @@ describe("services shared", () => { postgrest: "v14.13", storage: "v1.61.0", }); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("keeps an already-prefixed tenant version, including uppercase V", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "V14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("v1.77.1-versions"); + } - test("keeps an already-prefixed tenant version, including uppercase V", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "service-role-key", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === "/auth/v1/health") { - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/") { - return Response.json({ info: { version: "V14.13" } }); - } - - if (url.pathname === "/storage/v1/version") { - return new Response("v1.77.1-versions"); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -396,62 +388,62 @@ describe("services shared", () => { postgrest: "V14.13", storage: "v1.77.1-versions", }); - } finally { - await server.stop(true); - } - }); + }), + ); - test("falls back to empty linked versions when the linked fetch fails", async () => { - const result = await runLinkedFetch({ - apiUrl: "http://127.0.0.1:1", - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - }); + it.live("falls back to empty linked versions when the linked fetch fails", () => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: "http://127.0.0.1:1", + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + }); - expect(result).toEqual({}); - }); + expect(result).toEqual({}); + }), + ); + + it.live("authenticates tenant probes with apikey only for sb_ keys", () => + Effect.gen(function* () { + const authHeaders: Record = {}; + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "sb_secret_servicerolekey", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === "/auth/v1/health") { + authHeaders.apikey = request.headers.get("apikey"); + authHeaders.authorization = request.headers.get("authorization"); + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { + return new Response("not found", { status: 404 }); + } - test("authenticates tenant probes with apikey only for sb_ keys", async () => { - const authHeaders: Record = {}; - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "sb_secret_servicerolekey", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === "/auth/v1/health") { - authHeaders.apikey = request.headers.get("apikey"); - authHeaders.authorization = request.headers.get("authorization"); - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { return new Response("not found", { status: 404 }); - } - - return new Response("not found", { status: 404 }); - }, - }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -463,21 +455,19 @@ describe("services shared", () => { expect(result).toEqual({ auth: "v2.190.0" }); expect(authHeaders.apikey).toBe("sb_secret_servicerolekey"); expect(authHeaders.authorization).toBeNull(); - } finally { - await server.stop(true); - } - }); - - test("skips remote lookups for a malformed project ref", async () => { - const server = Bun.serve({ - port: 0, - fetch() { - throw new Error("no request should be made for a malformed project ref"); - }, - }); + }), + ); + + it.live("skips remote lookups for a malformed project ref", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch() { + throw new Error("no request should be made for a malformed project ref"); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: "not-a-valid-ref", @@ -486,13 +476,11 @@ describe("services shared", () => { }); expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + }), + ); test("renders the local services table with expected headers and rows", () => { - const rows = listLocalServiceVersions(); + const rows = listLocalServiceVersions({ slim: false }); const table = renderServicesTable(rows); expect(table).toContain("SERVICE IMAGE"); diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index af0635fc11..e5fb6b0521 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -1,3 +1,5 @@ +import { Config, ConfigProvider, Effect, Option } from "effect"; + const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; @@ -41,11 +43,13 @@ const V_PREFIXED_SERVICES: ReadonlySet = new Set([ "pooler", ]); -/** Reads the ambient slim-image flag for callers without an explicit project value. */ -export function slimImagesEnabled(): boolean { - const value = process.env[SLIM_IMAGES_ENV]; - return value === "true" || value === "1"; -} +/** + * Reads the ambient slim-image flag for callers without an explicit project value: always the + * process environment, never the active `ConfigProvider`. That lookup cannot fail. + */ +export const slimImagesEnabled = Effect.suspend(() => + Config.option(Config.string(SLIM_IMAGES_ENV)).parse(ConfigProvider.fromEnv()), +).pipe(Effect.map(Option.exists((value) => value === "true" || value === "1")), Effect.orDie); /** * Catalog-normalized slim tag under `ghcr.io/supabase/cli/`. The @@ -111,8 +115,8 @@ export function toSlimImage(alias: string, image: string): string { } /** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ -export function slimImageForAlias(alias: string, image: string): string { - return slimImagesEnabled() ? toSlimImage(alias, image) : image; +export function slimImageForAlias(alias: string, image: string, enabled: boolean): string { + return enabled ? toSlimImage(alias, image) : image; } export function imageTag(image: string): string | undefined { @@ -152,8 +156,8 @@ export function pinMatchesCurrentImage( export function slimImageForCurrentPin( alias: string, currentRawImage: string, - pin?: string, - enabled = slimImagesEnabled(), + pin: string | undefined, + enabled: boolean, ): string { const trimmed = pin?.trim() ?? ""; const tagged = trimmed.length > 0 ? replaceImageTag(currentRawImage, trimmed) : currentRawImage; @@ -176,6 +180,6 @@ export function isSlimImageRef(image: string): boolean { * one-shot jobs use this so a ghcr-shaped override with the flag off stays on * the docker.io contract. */ -export function usesSlimImageRuntime(image: string): boolean { - return slimImagesEnabled() && isSlimImageRef(image); +export function usesSlimImageRuntime(image: string, enabled: boolean): boolean { + return enabled && isSlimImageRef(image); } diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index 4e082a3b7c..654ea9b1fd 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -1,4 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; +import { vi } from "vitest"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { @@ -11,10 +13,6 @@ import { usesSlimImageRuntime, } from "./slim-images.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("toSlimImage", () => { it.each([ ["pg", "ghcr.io/supabase/cli/postgres"], @@ -106,31 +104,53 @@ describe("toSlimImage", () => { }); describe("slimImagesEnabled", () => { - it.each([ - ["true", true], - ["1", true], - ["false", false], - ["0", false], - ["yes", false], - ["TRUE", false], - ["", false], - ])("reads %j as %s", (value, expected) => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); - expect(slimImagesEnabled()).toBe(expected); - }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect.each([ + { value: "true", expected: true }, + { value: "1", expected: true }, + { value: "false", expected: false }, + { value: "0", expected: false }, + { value: "yes", expected: false }, + { value: "TRUE", expected: false }, + { value: "", expected: false }, + { value: undefined, expected: false }, + ])("reads $value as $expected", ({ value, expected }) => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); + expect(yield* slimImagesEnabled).toBe(expected); + }), + ); + + it.effect("reads the process environment, not the active ConfigProvider", () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const pinned = ConfigProvider.fromEnvRecord({ SUPABASE_USE_SLIM_IMAGES: "true" }); + const failing = ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ); + for (const provider of [pinned, failing]) { + expect( + yield* slimImagesEnabled.pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + ), + ).toBe(false); + } + }), + ); }); describe("slimImageForAlias", () => { it("is a no-op while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", false)).toBe( "supabase/postgres:17.6.1.165", ); }); it("translates when the flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", true)).toBe( "ghcr.io/supabase/cli/postgres:17.6.1.165", ); }); @@ -138,14 +158,12 @@ describe("slimImageForAlias", () => { describe("usesSlimImageRuntime", () => { it("is false while the flag is off even for a ghcr ref", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165")).toBe(false); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165", false)).toBe(false); }); it("is true only when the flag is on and the ref is slim", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0")).toBe(true); - expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0")).toBe(false); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0", true)).toBe(true); + expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0", true)).toBe(false); }); }); @@ -162,22 +180,22 @@ describe("pinMatchesCurrentImage", () => { describe("slimImageForCurrentPin", () => { it("slim-translates the current pin and leaves a historical pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const current = dockerfileServiceImageRaw("storage"); const currentTag = current.split(":")[1] ?? ""; - expect(slimImageForCurrentPin("storage", current)).toBe(toSlimImage("storage", current)); - expect(slimImageForCurrentPin("storage", current, currentTag)).toBe( + expect(slimImageForCurrentPin("storage", current, undefined, true)).toBe( + toSlimImage("storage", current), + ); + expect(slimImageForCurrentPin("storage", current, currentTag, true)).toBe( toSlimImage("storage", current), ); - expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + expect(slimImageForCurrentPin("storage", current, "v1.67.0", true)).toBe( "supabase/storage-api:v1.67.0", ); }); it("is a no-op while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); const current = dockerfileServiceImageRaw("storage"); - expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + expect(slimImageForCurrentPin("storage", current, "v1.67.0", false)).toBe( "supabase/storage-api:v1.67.0", ); }); From eb0c3ff6a70e144e33173558130a900a81adc450 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:39:50 +0000 Subject: [PATCH 58/71] fix(config): ignore unset sms provider (CLI-2463) (#6685) ## TL;DR stops `config diff` from reporting `auth.sms..enabled` on projects that never set up sms. ## whats broken? `sms_provider` reads `twilio` on projects that never set up sms, and the diff took it as an enabled provider. every such project reported `auth.sms.twilio.enabled` and no `config.toml` could clear it. ## now fixed by: a provider whose identity attribute (like the twilio account sid) is explicitly unset now counts as disabled projects that never set up sms diff clean, and configured providers are reported as before, a provider declared locally shows as disabled against such a project until it is pushed.. ## ref: - closes: https://github.com/supabase/cli/issues/6680 - broken in: https://github.com/supabase/cli/pull/6339 --- .../config/diff/diff.integration.test.ts | 42 +++++++++++++++++++ .../config/pull/pull.integration.test.ts | 34 +++++++++++++++ apps/cli/tests/helpers/config-fixtures.ts | 6 +++ .../project-config.unit.test.ts | 35 ++++++++++++++++ .../src/project-config/registry-auth.ts | 28 +++++++++---- 5 files changed, 136 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index b2d707b533..77f19458ce 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -202,6 +202,48 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("a project that never set up SMS diffs clean against a declared enabled = false", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth.sms.twilio]\nenabled = false\n', + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("a configured provider still diffs clean", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[auth.sms.twilio]", + "enabled = true", + 'account_sid = "AC1"', + 'message_service_sid = "MG1"', + 'auth_token = "env(TWILIO_AUTH_TOKEN)"', + "", + ].join("\n"), + dotenv: "TWILIO_AUTH_TOKEN=token\n", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + ...(attributes["auth"] as Record), + sms_twilio_account_sid: "AC1", + sms_twilio_message_service_sid: "MG1", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + it.live("--exit-code sets exit 2 when differences are found", () => { const { layer, processControl } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 1b5835b231..30f0a36ff4 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -2239,6 +2239,40 @@ describe("config pull integration", () => { }, ); + it.live("a declared provider is written back disabled by a remote that never set up SMS", () => { + const never = { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + ...(attributes["auth"] as Record), + sms_provider: "twilio", + sms_twilio_account_sid: null, + }, + }), + }), + }; + const declared = TWILIO_BEFORE.replace("enabled = false", "enabled = true") + .replace('account_sid = ""', 'account_sid = "ACdeclared"') + .replace('message_service_sid = ""', 'message_service_sid = "MGdeclared"'); + const { layer } = setup({ toml: declared, yes: true, v2: never }); + return withEnvVar( + TWILIO_AUTH_TOKEN_VAR, + "a-real-secret-value", + Effect.gen(function* () { + yield* configPull(noFlags); + const after = yield* readConfig; + expect(after).toContain("enabled = false"); + expect(after).toContain('account_sid = "ACdeclared"'); + + const second = setup({ toml: after, yes: true, v2: never }); + yield* configPull(noFlags).pipe(Effect.provide(second.layer)); + expect(second.out.stdoutText).toContain("No config differences found."); + }), + ).pipe(Effect.provide(layer)); + }); + it.live( "twilio scenario B: the schema-validation gate drops the whole family when a sibling stays unwritable (remote silent on message_service_sid), and the written file still reloads", () => { diff --git a/apps/cli/tests/helpers/config-fixtures.ts b/apps/cli/tests/helpers/config-fixtures.ts index 74a962a86d..a9b22abffa 100644 --- a/apps/cli/tests/helpers/config-fixtures.ts +++ b/apps/cli/tests/helpers/config-fixtures.ts @@ -90,6 +90,12 @@ export function v2ProjectConfigResponse( mailer_notifications_mfa_factor_enrolled_enabled: false, mailer_notifications_mfa_factor_unenrolled_enabled: false, external_phone_enabled: false, + sms_provider: "twilio", + sms_twilio_account_sid: null, + sms_twilio_verify_account_sid: null, + sms_messagebird_originator: null, + sms_textlocal_sender: null, + sms_vonage_from: null, sms_autoconfirm: false, sms_max_frequency: 5, sms_otp_exp: 60, diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index 9bdf80ee87..4961bf83ea 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -1101,6 +1101,41 @@ describe("fromApiProjectConfig — auth section", () => { }); }); + test("a named provider with a null identity attribute is unconfigured, never enabled", () => { + const result = fromApiProjectConfig({ + auth: { sms_provider: "twilio", external_phone_enabled: false, sms_twilio_account_sid: null }, + }); + expect(result.auth?.sms).toEqual({ + enable_signup: false, + twilio: { enabled: false }, + twilio_verify: { enabled: false }, + messagebird: { enabled: false }, + textlocal: { enabled: false }, + vonage: { enabled: false }, + }); + }); + + test.each([ + ["twilio", "sms_twilio_account_sid"], + ["twilio_verify", "sms_twilio_verify_account_sid"], + ["messagebird", "sms_messagebird_originator"], + ["textlocal", "sms_textlocal_sender"], + ["vonage", "sms_vonage_from"], + ] as const)("%s is enabled only with its identity attribute set", (provider, key) => { + const auth = { sms_provider: provider, external_phone_enabled: true }; + const identified = fromApiProjectConfig({ auth: { ...auth, [key]: "id" } }); + expect(identified.auth?.sms?.[provider]?.enabled).toBe(true); + for (const blank of ["", null]) { + const result = fromApiProjectConfig({ auth: { ...auth, [key]: blank } }); + expect(result.auth?.sms?.[provider]?.enabled).toBe(false); + } + }); + + test("an identity attribute without sms_provider names no provider", () => { + const result = fromApiProjectConfig({ auth: { sms_twilio_account_sid: "AC1" } }); + expect(result.auth?.sms).toEqual({ twilio: { account_sid: "AC1" } }); + }); + test("external_github_enabled maps to auth.external.github.enabled", () => { const result = fromApiProjectConfig({ auth: { external_github_enabled: true } }); expect(result.auth?.external?.github).toEqual({ enabled: true }); diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index f456fc1bdd..28ef052255 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -804,23 +804,33 @@ const smsBaseRows: ReadonlyArray = [ }, ]; -// A single `sms_provider` string names exactly one active provider; reconciled unconditionally -// since a standalone mapping has no local document to consult for "already enabled". An -// unrecognized value maps every provider's `enabled` to `false` rather than surfacing as a bug — -// there's no single field to flag it against, but the raw string stays reachable at -// `_apiResponse.auth.sms_provider`. +// A single `sms_provider` string names exactly one active provider, but a provider whose identity +// attribute is explicitly unset is unconfigured, never enabled. An unrecognized value maps +// every provider's `enabled` to `false` rather than surfacing as a bug — there's no single field to +// flag it against, but the raw string stays reachable at `_apiResponse.auth.sms_provider`. const SMS_PROVIDERS = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"] as const; +const SMS_IDENTITY_ATTRIBUTES: Record<(typeof SMS_PROVIDERS)[number], string> = { + twilio: "sms_twilio_account_sid", + twilio_verify: "sms_twilio_verify_account_sid", + messagebird: "sms_messagebird_originator", + textlocal: "sms_textlocal_sender", + vonage: "sms_vonage_from", +}; + const smsProviderSelectionRows: ReadonlyArray = SMS_PROVIDERS.map( (provider) => ({ configPath: ["auth", "sms", provider, "enabled"], apiPath: ["auth", "sms_provider"], - // Null/empty → omit all five (no provider named); a non-string throws like every other + alsoConsumes: [["auth", SMS_IDENTITY_ATTRIBUTES[provider]]], + // Absent/null/empty → omit all five (no provider named); a non-string throws like every other // mapped field. - transform: (value) => { - if (value === null) return undefined; + transform: (value, attributes) => { + if (value === undefined || value === null) return undefined; const named = expectString(value, ["auth", "sms_provider"]); - return named.length > 0 ? named === provider : undefined; + if (named.length === 0) return undefined; + const identity = readAuthAttribute(attributes, SMS_IDENTITY_ATTRIBUTES[provider]); + return named === provider && identity !== null && identity !== ""; }, }), ); From 233d1459e22ad14be9104ceb1254a7c3fe11b84d Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:52:13 +0000 Subject: [PATCH 59/71] fix(cli): stop splitting escape strings on escaped quotes (CLI-2537) (#6906) ## TL;DR fixes the sql splitter cutting `E'...'` strings in two at a `;` that follows a backslash escaped quote. ## whats broken? the splitter only treated a doubled quote as staying inside a string, so in `select E'it\'s; here';` the `\'` closed the literal and the `;` split the statement. migrations and seeds go thru this splitter, and postgres rejects the first half `select E'it\'s` with `unterminated quoted string`.. ## now fixed by: treating `'` as the start of an escape string when the `E` or `e` before it starts a token, and skipping the character after each backslash inside it. an escape string continued on a following line, `E'first'` then `'second\'; third'`, now stays one literal the way the postgres server reads it. plain strings, `type'a\'`, `U&'a\'` and quoted identifiers keep the backslash literal as before. ## ref: - closes: https://github.com/supabase/cli/issues/6885 --- apps/cli/src/command-internal/sql-split.ts | 66 +++++++++++-- .../command-internal/sql-split.unit.test.ts | 94 ++++++++++++++++++- 2 files changed, 153 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 0f3fb1665c..66c7a71742 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -40,6 +40,9 @@ function endsWithKeyword(data: string, keyword: string): boolean { const isSqlWhitespace = (rune: string): boolean => " \t\n\r\f\v".includes(rune); +// scan.l `newline`: a `--` comment ends at either. +const isNewline = (rune: string): boolean => rune === "\n" || rune === "\r"; + function isBeginAtomic(data: string): boolean { if (!endsWithKeyword(data, BEGIN_ATOMIC)) return false; let end = data.length - BEGIN_ATOMIC.length; @@ -53,9 +56,9 @@ function isCommentsAndWhitespace(text: string): boolean { if (isSqlWhitespace(text[i]!)) { i += 1; } else if (text.startsWith("--", i)) { - const newline = text.indexOf("\n", i + 2); + const newline = text.slice(i + 2).search(/[\n\r]/u); if (newline === -1) return true; - i = newline + 1; + i += newline + 3; } else if (text.startsWith("/*", i)) { // Match `BlockState`'s sliding-window scan so both agree on overlapping delimiters. let depth = 1; @@ -85,8 +88,12 @@ class ReadyState implements State { return new TagState(offset); } case "'": + // `E'…'` is an escape string constant only when the `E` starts a token (scan.l + // `xestart`); in `type'…'` it ends an identifier. A digit or `$` before the `E` + // counts as one too, unlike PostgreSQL; valid SQL never has that. + return new QuoteState(rune, endsWithKeyword(data.slice(0, -1), "E")); case '"': - return new QuoteState(rune); + return new QuoteState(rune, false); case "-": return new CommentState(); case "/": @@ -109,12 +116,17 @@ class ReadyState implements State { class CommentState implements State { next(rune: string, data: string): State | null { - // A line comment escapes nothing until the newline — same shape as a dollar quote. - if (rune === "-") return new DollarState("\n"); + if (rune === "-") return new LineCommentState(); return new ReadyState().next(rune, data); } } +class LineCommentState implements State { + next(rune: string): State { + return isNewline(rune) ? new ReadyState() : this; + } +} + class BlockState implements State { private depth = 0; next(rune: string, data: string): State | null { @@ -134,7 +146,11 @@ class BlockState implements State { class QuoteState implements State { private escape = false; - constructor(private readonly delimiter: string) {} + private backslash = false; + constructor( + private readonly delimiter: string, + private readonly backslashEscapes: boolean, + ) {} next(rune: string, data: string): State | null { if (this.escape) { // Preserve a doubled quote ('' or ""). @@ -142,13 +158,51 @@ class QuoteState implements State { this.escape = false; return this; } + if (this.backslashEscapes) return new QuoteContinueState().next(rune, data); return new ReadyState().next(rune, data); } + if (this.backslash) { + // Preserve the rune after a backslash (\' or \\). + this.backslash = false; + return this; + } + if (this.backslashEscapes && rune === "\\") { + this.backslash = true; + return this; + } if (rune === this.delimiter) this.escape = true; return this; } } +// After an escape string's closing quote, whitespace holding a newline and then a quote +// continues the same literal (scan.l `quotecontinue`); `--` comments count as whitespace. +class QuoteContinueState implements State { + private newline = false; + private dashes = 0; + next(rune: string, data: string): State | null { + if (this.dashes === 2) { + if (isNewline(rune)) { + this.dashes = 0; + this.newline = true; + } + return this; + } + if (rune === "-") { + this.dashes += 1; + return this; + } + if (this.dashes === 0) { + if (isSqlWhitespace(rune)) { + this.newline ||= isNewline(rune); + return this; + } + if (this.newline && rune === "'") return new QuoteState(rune, true); + } + return new ReadyState().next(rune, data); + } +} + class DollarState implements State { constructor(private readonly delimiter: string) {} next(_rune: string, data: string): State | null { diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index d8b1febd22..b904d19e85 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findDropStatements, splitAndTrim, splitSql } from "./sql-split.ts"; +import { findDropStatements, splitAndTrim, splitSql, splitSqlTokens } from "./sql-split.ts"; describe("splitAndTrim", () => { it("splits simple statements and trims trailing ; + whitespace", () => { @@ -23,6 +23,85 @@ describe("splitAndTrim", () => { expect(splitAndTrim("SELECT 'a''; b'; SELECT 2")).toEqual(["SELECT 'a''; b'", "SELECT 2"]); }); + it.each([ + String.raw`SELECT E'it\'s; here'`, + String.raw`SELECT e'it\'s; here'`, + String.raw`E'it\'s; here'`, + String.raw`SELECT (E'a\'; b'),E'c\'; d',1=E'e\'; f'`, + String.raw`SELECT E'a\\\'; b'`, + String.raw`SELECT E'a''\'; b'`, + String.raw`SELECT 'a'E'b\'; c'`, + String.raw`CREATE FUNCTION f() BEGIN ATOMIC SELECT E'a\'; END; b'; END`, + String.raw`SELECT $$a$$ /* b */E'c\'; d'`, + ])("keeps a backslash-escaped quote inside an escape string: %s", (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + expect(splitSqlTokens(`${statement}; SELECT 2`).map((token) => token.trimmed)).toEqual([ + statement, + "SELECT 2", + ]); + }); + + it.each([String.raw`SELECT E'a\\'`, String.raw`SELECT E'a''; b'`, String.raw`SELECT E'a\\\\'`])( + "ends an escape string at its closing quote: %s", + (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + }, + ); + + it("keeps an unterminated escape string ending in a backslash", () => { + const sql = String.raw`SELECT E'a; b` + "\\"; + expect(splitAndTrim(sql)).toEqual([sql]); + expect(splitSqlTokens(sql)).toEqual([{ raw: sql, trimmed: sql, terminated: false }]); + }); + + it.each([ + String.raw`SELECT 'a\'`, + String.raw`SELECT type'a\'`, + String.raw`SELECT éE'a\'`, + String.raw`SELECT 😀E'a\'`, + String.raw`SELECT _e'a\'`, + String.raw`SELECT U&'a\'`, + String.raw`SELECT 1 AS E"a\"`, + ])("keeps the backslash literal outside escape strings: %s", (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + }); + + it.each([ + "SELECT E'first'\n'second\\'; third'", + "SELECT E'first'\r\n'second\\'; third'", + "SELECT E'first'\r'second\\'; third'", + "SELECT E'first' \t\v\f\n\n \v'second\\'; third'", + "SELECT E'a'\n'b'\n'c\\'; d'", + "SELECT E'a'''\n'b\\'; c'", + "SELECT E'a' -- note;\n -- more\n'b\\'; c'", + "SELECT E'a' -- note;\r'b\\'; c'", + "SELECT (E'a'\n'b\\'; c')", + "CREATE FUNCTION f() BEGIN ATOMIC SELECT E'a'\n'b\\'; END; c'; END", + ])("keeps an escape string continued on a later line together: %j", (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + expect(splitSqlTokens(`${statement}; SELECT 2`).map((token) => token.trimmed)).toEqual([ + statement, + "SELECT 2", + ]); + }); + + it.each([ + ["SELECT E'a' 'b\\'; SELECT 2", ["SELECT E'a' 'b\\'", "SELECT 2"]], + ["SELECT E'a'\n/* x */'b\\'; SELECT 2", ["SELECT E'a'\n/* x */'b\\'", "SELECT 2"]], + ["SELECT E'a'\n-'b\\'; SELECT 2", ["SELECT E'a'\n-'b\\'", "SELECT 2"]], + ["SELECT E'a'\n- 'b\\'; SELECT 2", ["SELECT E'a'\n- 'b\\'", "SELECT 2"]], + ["SELECT E'a'\n\"b\\\"; SELECT 2", ["SELECT E'a'\n\"b\\\"", "SELECT 2"]], + [ + "SELECT E'a' -- c\r|| 'b'\n'c\\'; SELECT 'd', 'e;f'", + ["SELECT E'a' -- c\r|| 'b'\n'c\\'", "SELECT 'd', 'e;f'"], + ], + ["SELECT E'a';\n'b\\'; c'", ["SELECT E'a'", "'b\\'", "c'"]], + ["SELECT 'a'\n'b\\'; SELECT 2", ["SELECT 'a'\n'b\\'", "SELECT 2"]], + ])("starts a standard string when it is not a continuation: %j", (sql, statements) => { + expect(splitAndTrim(sql)).toEqual(statements); + expect(splitSqlTokens(sql).map((token) => token.trimmed)).toEqual(statements); + }); + it("does not split on a ; inside a dollar-quoted function body", () => { const sql = "CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql; SELECT 2;"; @@ -49,6 +128,13 @@ describe("splitAndTrim", () => { expect(splitAndTrim("SELECT 1 -- a; b\n; SELECT 2")).toEqual(["SELECT 1 -- a; b", "SELECT 2"]); }); + it.each(["E'a'", "'a'"])("ends a line comment after %s at a bare carriage return", (literal) => { + expect(splitAndTrim(`SELECT ${literal} -- a; b\r; SELECT 2`)).toEqual([ + `SELECT ${literal} -- a; b`, + "SELECT 2", + ]); + }); + it("ignores a ; inside a block comment (nested)", () => { expect(splitAndTrim("SELECT 1 /* a; /* n; */ b; */; SELECT 2")).toEqual([ "SELECT 1 /* a; /* n; */ b; */", @@ -106,6 +192,12 @@ describe("splitAndTrim", () => { expect(splitAndTrim(`${body}; SELECT 2;`)).toEqual([body, "SELECT 2"]); }); + it("does not close a BEGIN ATOMIC body at an END after a carriage-return-ended comment", () => { + const body = + "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; -- c\rSELECT CASE WHEN true THEN 2 END; END"; + expect(splitAndTrim(`${body}; SELECT 3;`)).toEqual([body, "SELECT 3"]); + }); + it.each(["-- note END\n", "/* note; */ ", "\n/* a /* b; */ */ -- c\n"])( "closes a BEGIN ATOMIC body at an END preceded only by comments (%s)", (comment) => { From b202ba6e6778b6f7d3cc852cc1fdf3a7534d8ef7 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:06:54 +0000 Subject: [PATCH 60/71] refactor(cli): cover `shared/output` with effect lint (CLI-2561) (#6912) ## TL;DR brings the `output` area under the effect lint ## whats introduced? effect lint applied to the output layers, the output service and their tests: - `shared/output/**` allow list entry - the delayed task spinner is a fiber owned by the text layer instead of a `setTimeout`, so a task still pending when the layer closes no longer shows a spinner afterwards - `stream-json` events take their timestamp from the effect `Clock` when the line is written, same format as before - flagged `JSON.stringify` writes go through a `Schema` json encoder, same bytes out, and an unserializable payload still dies as a `TypeError` - `OutputTask.clear` is a plain effect instead of a zero arg function, so callers and mocks use `task.clear` - tests use `TestClock` instead of fake timers, a schema decoder instead of `JSON.parse`, and pin the exact `stream-json` lines ## ref: - closes: CLI-2561 --- .oxlintrc.effect.json | 1 + .../cli/src/command-internal/branch-target.ts | 2 +- .../src/command-internal/config-pull-run.ts | 2 +- .../pooler-fallback.unit.test.ts | 2 +- .../command-internal/project-create-core.ts | 2 +- .../src/command-internal/project-target.ts | 2 +- .../src/commands/backups/list/list.handler.ts | 2 +- .../backups/restore/restore.handler.ts | 2 +- .../branches/create/create.handler.ts | 2 +- .../branches/delete/delete.handler.ts | 2 +- .../branches/disable/disable.handler.ts | 2 +- .../src/commands/branches/get/get.handler.ts | 2 +- .../commands/branches/list/list.handler.ts | 2 +- .../commands/branches/pause/pause.handler.ts | 2 +- .../branches/unpause/unpause.handler.ts | 2 +- .../branches/update/update.handler.ts | 2 +- .../src/commands/config/diff/diff.handler.ts | 2 +- .../config/push/push.branch-target.ts | 2 +- .../domains/activate/activate.handler.ts | 2 +- .../commands/domains/create/create.handler.ts | 2 +- .../commands/domains/delete/delete.handler.ts | 2 +- .../src/commands/domains/get/get.handler.ts | 2 +- .../domains/reverify/reverify.handler.ts | 2 +- .../get-root-key/get-root-key.handler.ts | 2 +- .../update-root-key.handler.ts | 2 +- .../compute/delete/delete.handler.ts | 4 +- .../experimental/compute/list/list.handler.ts | 2 +- .../experimental/compute/logs/logs.handler.ts | 4 +- .../experimental/compute/push/push.handler.ts | 8 +- .../compute/status/status.handler.ts | 2 +- .../stack/destroy/destroy.handler.ts | 2 +- .../stack/prepare/prepare.handler.ts | 2 +- .../stack/restart/restart.handler.ts | 2 +- .../src/commands/feedback/feedback-task.ts | 4 +- .../feedback/feedback-task.unit.test.ts | 2 +- .../commands/functions/list/list.handler.ts | 2 +- .../commands/issue/issue.integration.test.ts | 2 +- apps/cli/src/commands/link/link.handler.ts | 4 +- .../commands/network-bans/get/get.handler.ts | 2 +- .../network-restrictions/get/get.handler.ts | 2 +- .../update/update.handler.ts | 4 +- .../commands/notebooks/notebooks.shared.ts | 2 +- .../commands/orgs/create/create.handler.ts | 2 +- .../src/commands/orgs/list/list.handler.ts | 2 +- .../postgres-config/delete/delete.handler.ts | 2 +- .../postgres-config/get/get.handler.ts | 2 +- .../postgres-config/update/update.handler.ts | 2 +- .../projects/api-keys/api-keys.handler.ts | 2 +- .../projects/delete/delete.handler.ts | 2 +- .../commands/projects/list/list.handler.ts | 2 +- .../src/commands/secrets/list/list.handler.ts | 2 +- .../src/commands/secrets/set/set.handler.ts | 2 +- .../commands/secrets/unset/unset.handler.ts | 2 +- .../snippets/download/download.handler.ts | 2 +- .../commands/snippets/list/list.handler.ts | 2 +- .../ssl-enforcement/get/get.handler.ts | 2 +- .../ssl-enforcement/update/update.handler.ts | 2 +- apps/cli/src/commands/sso/add/add.handler.ts | 2 +- .../cli/src/commands/sso/list/list.handler.ts | 2 +- .../src/commands/sso/remove/remove.handler.ts | 2 +- .../cli/src/commands/sso/show/show.handler.ts | 2 +- .../src/commands/sso/update/update.handler.ts | 2 +- .../activate/activate.handler.ts | 2 +- .../check-availability.handler.ts | 2 +- .../delete/delete.handler.ts | 2 +- .../vanity-subdomains/get/get.handler.ts | 2 +- .../cli/src/commands/whoami/whoami.handler.ts | 2 +- .../quiet-progress-text-output.layer.ts | 2 +- ...et-progress-text-output.layer.unit.test.ts | 7 +- .../output/json-error-handling.unit.test.ts | 24 +- apps/cli/src/shared/output/output.layer.ts | 208 ++++++++---------- .../shared/output/output.layer.unit.test.ts | 148 +++++++------ apps/cli/src/shared/output/output.service.ts | 2 +- apps/cli/src/shared/output/table.unit.test.ts | 52 +++-- apps/cli/tests/helpers/mocks.ts | 2 +- 75 files changed, 294 insertions(+), 300 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 0e8b78b348..137f3fb227 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -19,6 +19,7 @@ "!apps/cli/src/shared/git/**", "!apps/cli/src/shared/init/**", "!apps/cli/src/shared/issue/**", + "!apps/cli/src/shared/output/**", "!apps/cli/src/shared/runtime/**", "!apps/cli/src/shared/services/**", "!apps/cli/src/shared/telemetry/**", diff --git a/apps/cli/src/command-internal/branch-target.ts b/apps/cli/src/command-internal/branch-target.ts index 0a19e5a22f..8481b4c21d 100644 --- a/apps/cli/src/command-internal/branch-target.ts +++ b/apps/cli/src/command-internal/branch-target.ts @@ -127,7 +127,7 @@ export const findBranchName = Effect.fnUntraced(function* ( : undefined; return yield* api.v1 .listAllBranches({ ref: parentRef }) - .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear() ?? Effect.void)); + .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear ?? Effect.void)); }).pipe( Effect.timeout(BRANCH_LOOKUP_TIMEOUT), // Any failure or the timeout above degrades to `None`; this helper never fails on a diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 4ef17d7449..9395168666 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -675,7 +675,7 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // Normalizes and classifies the response through the config family's shared // typed/defect boundary (ADR 0021). diff --git a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts index 489fb65380..b32a0f8444 100644 --- a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts +++ b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts @@ -37,7 +37,7 @@ function captureOutput() { fail: () => Effect.void, info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), promptText: () => Effect.die("unexpected promptText"), promptPassword: () => Effect.die("unexpected promptPassword"), diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 3c7cd8f5c4..aa1aca15e3 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -140,7 +140,7 @@ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCrea } const created = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const id = readProjectField(created, "id"); diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 5a032a9270..6ba150f089 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -196,7 +196,7 @@ export function resolveConfigTarget fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index 991ba7ef20..de62569194 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -41,7 +41,7 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac Effect.tapError(() => restoring?.fail() ?? Effect.void), Effect.catch(mapRestoreError), ); - yield* restoring?.clear() ?? Effect.void; + yield* restoring?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index ed5e8684db..4a83774540 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -120,7 +120,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra ), ), ); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index c3422efe50..c9c22c378f 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -46,7 +46,7 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success("Deleted preview branch", { project_ref: branchRef }); diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index 2bd1903596..3ee6f6e07a 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -39,7 +39,7 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( Effect.tapError(() => disabling?.fail() ?? Effect.void), Effect.catch(mapDisableError), ); - yield* disabling?.clear() ?? Effect.void; + yield* disabling?.clear ?? Effect.void; // Established behavior: this message writes to STDOUT. if (output.format === "json" || output.format === "stream-json") { diff --git a/apps/cli/src/commands/branches/get/get.handler.ts b/apps/cli/src/commands/branches/get/get.handler.ts index 8de72c4661..8ba167a3fb 100644 --- a/apps/cli/src/commands/branches/get/get.handler.ts +++ b/apps/cli/src/commands/branches/get/get.handler.ts @@ -106,7 +106,7 @@ export const branchesGet = Effect.fn("branches.get")(function* (flags: BranchesG Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const detail: BranchDetail = { ...rawDetail, db_user: rawDetail.db_user ?? "******", diff --git a/apps/cli/src/commands/branches/list/list.handler.ts b/apps/cli/src/commands/branches/list/list.handler.ts index 6f8d6a2f2d..3e3ddbc0d5 100644 --- a/apps/cli/src/commands/branches/list/list.handler.ts +++ b/apps/cli/src/commands/branches/list/list.handler.ts @@ -48,7 +48,7 @@ export const branchesList = Effect.fn("branches.list")(function* (flags: Branche Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/branches/pause/pause.handler.ts b/apps/cli/src/commands/branches/pause/pause.handler.ts index 80ebca2423..3ad94a3b86 100644 --- a/apps/cli/src/commands/branches/pause/pause.handler.ts +++ b/apps/cli/src/commands/branches/pause/pause.handler.ts @@ -42,6 +42,6 @@ export const branchesPause = Effect.fn("branches.pause")(function* (flags: Branc Effect.tapError(() => pausing?.fail() ?? Effect.void), Effect.catch(mapPauseError), ); - yield* pausing?.clear() ?? Effect.void; + yield* pausing?.clear ?? Effect.void; }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/branches/unpause/unpause.handler.ts b/apps/cli/src/commands/branches/unpause/unpause.handler.ts index 98a183ae4c..3c030b4d5e 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.handler.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.handler.ts @@ -45,6 +45,6 @@ export const branchesUnpause = Effect.fn("branches.unpause")(function* ( Effect.tapError(() => restoring?.fail() ?? Effect.void), Effect.catch(mapUnpauseError), ); - yield* restoring?.clear() ?? Effect.void; + yield* restoring?.clear ?? Effect.void; }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index eae4f83dd8..28d3fa838e 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -83,7 +83,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra ), ), ); - yield* patching?.clear() ?? Effect.void; + yield* patching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index 7672a7bcc7..b5c3d29d4b 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -173,7 +173,7 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // configProjectConfigTry (ADR 0021) keeps a response the schema can't narrow as a typed // ProjectConfigParseError; anything else escaping it is a defect. diff --git a/apps/cli/src/commands/config/push/push.branch-target.ts b/apps/cli/src/commands/config/push/push.branch-target.ts index 3cc428de05..74097558b2 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -145,7 +145,7 @@ export function resolveConfigPushTarget( ); // A definitive answer (200 or 404) clears the task; an uncertain one marks it failed // since the diagnostic step didn't complete, though the push proceeds regardless. - yield* (probe.kind === "unknown" ? probing?.fail() : probing?.clear()) ?? Effect.void; + yield* (probe.kind === "unknown" ? probing?.fail() : probing?.clear) ?? Effect.void; if (probe.kind === "project") { return { kind: "project", ref, ...(probe.name === undefined ? {} : { name: probe.name }) }; diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index 1271364b1e..cc267ad126 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -30,7 +30,7 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( Effect.tapError(() => activating?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapActivateError)), ); - yield* activating?.clear() ?? Effect.void; + yield* activating?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index 2d8d6a1b20..f65d28f597 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -46,7 +46,7 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapCreateError)), ); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index 3cd8967534..0cab96bb18 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -35,7 +35,7 @@ export const domainsDelete = Effect.fn("domains.delete")(function* (flags: Domai Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success(DELETE_SUCCESS_MESSAGE, {}); diff --git a/apps/cli/src/commands/domains/get/get.handler.ts b/apps/cli/src/commands/domains/get/get.handler.ts index 2d2245ff24..e1c1faa711 100644 --- a/apps/cli/src/commands/domains/get/get.handler.ts +++ b/apps/cli/src/commands/domains/get/get.handler.ts @@ -32,7 +32,7 @@ export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetF Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapGetError)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/commands/domains/reverify/reverify.handler.ts index fbbc5ab956..6b2e47c58d 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.handler.ts @@ -30,7 +30,7 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( Effect.tapError(() => reverifying?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapReverifyError)), ); - yield* reverifying?.clear() ?? Effect.void; + yield* reverifying?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts index 998e651d7e..c2fc3e1b0c 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts @@ -30,7 +30,7 @@ export const encryptionGetRootKey = Effect.fn("encryption.get-root-key")(functio Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; if (output.format !== "text") { // json / stream-json — emit a structured result. diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts index 45ed1ba0d5..0a776e7914 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts @@ -47,7 +47,7 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; if (output.format !== "text") { yield* output.success("", { root_key: response.root_key }); diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index 5555f4226e..b86d13a8d0 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -83,7 +83,7 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu ), Effect.tapError(() => fetching.fail()), ); - yield* fetching.clear(); + yield* fetching.clear; const deployed = lookup.compute; const machineOutput = yield* computeMachineOutputRequested(); @@ -153,7 +153,7 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu if (deployed !== undefined || !lookup.readable) { const deleting = yield* output.task("Deleting compute..."); yield* deleteCompute(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); - yield* deleting.clear(); + yield* deleting.clear; } // A compute deployed from another checkout has neither a local entry nor a diff --git a/apps/cli/src/commands/experimental/compute/list/list.handler.ts b/apps/cli/src/commands/experimental/compute/list/list.handler.ts index 604071f893..296e4ad1fb 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.handler.ts @@ -118,7 +118,7 @@ export const computeList = Effect.fn("compute.list")(function* (flags: ComputeLi const deployed = yield* listCompute(api, projectRef).pipe( Effect.tapError(() => fetching.fail()), ); - yield* fetching.clear(); + yield* fetching.clear; const byName = new Map(deployed.map((compute) => [compute.name, compute])); const configuredNames = Object.keys(project.section.compute); diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts index 13e084d1dd..cd26ce471b 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts @@ -236,7 +236,7 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( tail: flags.tail, window: logWindow(yield* DateTime.now), }).pipe(Effect.tapError(() => fetching.fail())); - yield* fetching.clear(); + yield* fetching.clear; return rows; }); @@ -256,7 +256,7 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( const deployed = yield* getCompute(api, projectRef, name).pipe( Effect.tapError(() => checking.fail()), ); - yield* checking.clear(); + yield* checking.clear; if (Option.isNone(deployed)) { return yield* new ComputeNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, diff --git a/apps/cli/src/commands/experimental/compute/push/push.handler.ts b/apps/cli/src/commands/experimental/compute/push/push.handler.ts index 08f71e1010..2d33bef76f 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -330,7 +330,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { const packaged = yield* packageComputeDirectory(compute.sourceDir, exclude).pipe( Effect.tapError(() => packaging.fail()), ); - yield* packaging.clear(); + yield* packaging.clear; // The excluded count rides along on the same line, and only when patterns are configured: // an over-broad pattern is otherwise visible only as a file count nobody had a number to // compare against, and by then the archive is already uploaded. @@ -366,7 +366,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { Effect.tapError(() => uploading.fail()), ); yield* uploadBuildContext(slot, packaged.archive).pipe(Effect.tapError(() => uploading.fail())); - yield* uploading.clear(); + yield* uploading.clear; yield* output.raw("Uploaded build context.\n", "stderr"); contextUploadId = slot.uploadId; } @@ -412,7 +412,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { // Checked regardless of whether the build was waited on: the verdict can // arrive on the deploy response as readily as on a poll. if (settled.buildState === "failed") { - yield* deploying.clear(); + yield* deploying.clear; return yield* new ComputeBuildFailedError({ detail: `The build for "${name}" failed${ settled.stateReason === undefined ? "" : `: ${settled.stateReason}` @@ -421,7 +421,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { }); } - yield* deploying.clear(); + yield* deploying.clear; const url = settled.spec.exposure === "public" diff --git a/apps/cli/src/commands/experimental/compute/status/status.handler.ts b/apps/cli/src/commands/experimental/compute/status/status.handler.ts index 9d1acc1be7..c3c7648976 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.handler.ts @@ -60,7 +60,7 @@ export const computeStatus = Effect.fn("compute.status")(function* (flags: Compu const found = yield* getCompute(api, projectRef, name).pipe( Effect.tapError(() => fetching.fail()), ); - yield* fetching.clear(); + yield* fetching.clear; if (Option.isNone(found)) { return yield* new ComputeNotDeployedError({ diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 76c17a6e19..606b0da0f8 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -100,7 +100,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( const result = yield* stack.destroy.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? destroying.clear() + ? destroying.clear : Cause.hasInterruptsOnly(exit.cause) ? destroying.cancel() : destroying.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts index 52deaefbe0..08f53cb662 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -160,7 +160,7 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( ).pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? task.clear() + ? task.clear : Option.match(Cause.findErrorOption(exit.cause), { onNone: () => (Cause.hasInterruptsOnly(exit.cause) ? task.cancel() : task.fail()), onSome: (error) => task.fail(error.message), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts index 26038645a9..898d1cf198 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -84,7 +84,7 @@ export const stackRestart = Effect.fn("experimental.stack.restart")(function* ( const observations = yield* stack.composition.restart.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? task.clear() + ? task.clear : Cause.hasInterruptsOnly(exit.cause) ? task.cancel() : task.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), diff --git a/apps/cli/src/commands/feedback/feedback-task.ts b/apps/cli/src/commands/feedback/feedback-task.ts index d1222551a4..2858f70ef1 100644 --- a/apps/cli/src/commands/feedback/feedback-task.ts +++ b/apps/cli/src/commands/feedback/feedback-task.ts @@ -14,9 +14,9 @@ export const settleFeedbackTask = effect.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? task.clear() + ? task.clear : Cause.hasInterruptsOnly(exit.cause) - ? task.clear() + ? task.clear : task.fail(), ), ); diff --git a/apps/cli/src/commands/feedback/feedback-task.unit.test.ts b/apps/cli/src/commands/feedback/feedback-task.unit.test.ts index 10c7fed0db..508e2066aa 100644 --- a/apps/cli/src/commands/feedback/feedback-task.unit.test.ts +++ b/apps/cli/src/commands/feedback/feedback-task.unit.test.ts @@ -11,7 +11,7 @@ function recordingTask() { fail: () => Effect.sync(() => void settles.push("fail")), info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.sync(() => void settles.push("clear")), + clear: Effect.sync(() => void settles.push("clear")), }; return { task, settles }; } diff --git a/apps/cli/src/commands/functions/list/list.handler.ts b/apps/cli/src/commands/functions/list/list.handler.ts index b48a2426a9..a2711b6ac5 100644 --- a/apps/cli/src/commands/functions/list/list.handler.ts +++ b/apps/cli/src/commands/functions/list/list.handler.ts @@ -86,7 +86,7 @@ export const functionsList = Effect.fn("functions.list")(function* (flags: Funct decode: true, }); } - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const { functions, isNil } = decodedFunctions.value; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/issue/issue.integration.test.ts b/apps/cli/src/commands/issue/issue.integration.test.ts index b41027eef6..8034a1d8ed 100644 --- a/apps/cli/src/commands/issue/issue.integration.test.ts +++ b/apps/cli/src/commands/issue/issue.integration.test.ts @@ -39,7 +39,7 @@ function issueMockOutput(opts: { readonly format?: OutputFormat } = {}) { fail: () => Effect.void, info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), promptText: () => Effect.succeed(""), promptPassword: () => Effect.succeed(""), diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index ea7c90fdaf..5a56ff300f 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -167,7 +167,7 @@ const resolveLinkBranchRef = Effect.fnUntraced(function* (value: string) { Effect.tapError(() => task?.fail() ?? Effect.void), Effect.catch(mapBranchListError), ); - yield* task?.clear() ?? Effect.void; + yield* task?.clear ?? Effect.void; const found: LinkBranch | undefined = branches.find( // UUID matching is case-insensitive (canonical ids are lowercase hex, but uppercase input @@ -393,7 +393,7 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { Effect.timeout(LINK_CACHE_CORRELATION_TIMEOUT), Effect.map((branches) => branches.some((branch) => branch.project_ref === ref)), Effect.orElseSucceed(() => false), - Effect.ensuring(correlating?.clear() ?? Effect.void), + Effect.ensuring(correlating?.clear ?? Effect.void), ); if (!verified) { yield* fs.remove(paths.linkedProjectCache, { force: true }).pipe(Effect.ignore); diff --git a/apps/cli/src/commands/network-bans/get/get.handler.ts b/apps/cli/src/commands/network-bans/get/get.handler.ts index 30e7fce6c6..97e101fd99 100644 --- a/apps/cli/src/commands/network-bans/get/get.handler.ts +++ b/apps/cli/src/commands/network-bans/get/get.handler.ts @@ -41,7 +41,7 @@ export const networkBansGet = Effect.fn("network-bans.get")(function* (flags: Ne Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/network-restrictions/get/get.handler.ts b/apps/cli/src/commands/network-restrictions/get/get.handler.ts index 827772f9cc..5b207123df 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.handler.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.handler.ts @@ -51,7 +51,7 @@ export const networkRestrictionsGet = Effect.fn("network-restrictions.get")(func Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/network-restrictions/update/update.handler.ts b/apps/cli/src/commands/network-restrictions/update/update.handler.ts index aff28d7ea4..4617ed42b6 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.handler.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.handler.ts @@ -84,7 +84,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; // The PATCH response always renders as `&[]`/`&[...]`, never ``; partition // returns concrete arrays to match, even when a type has no items. const partitioned = partitionPatchedCidrs(response.config.dbAllowedCidrs); @@ -103,7 +103,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; // POST /apply prints the response field directly; an omitted array renders as ``. v4Out = response.config.dbAllowedCidrs; v6Out = response.config.dbAllowedCidrsV6; diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 5c5652b064..2744d9026c 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -341,7 +341,7 @@ const withNotebookTask = return yield* Effect.acquireUseRelease( output.task(`${sanitizeInlineName(subject)}...`), () => self, - (task, exit) => (Exit.isFailure(exit) ? task.fail() : task.clear()), + (task, exit) => (Exit.isFailure(exit) ? task.fail() : task.clear), ); }); diff --git a/apps/cli/src/commands/orgs/create/create.handler.ts b/apps/cli/src/commands/orgs/create/create.handler.ts index b7a83a57da..bbcc52c849 100644 --- a/apps/cli/src/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/commands/orgs/create/create.handler.ts @@ -40,7 +40,7 @@ export const orgsCreate = Effect.fn("orgs.create")(function* (flags: OrgsCreateF Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.catch(mapCreateError), ); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/orgs/list/list.handler.ts b/apps/cli/src/commands/orgs/list/list.handler.ts index 685081a4e9..3e3a9f7bd9 100644 --- a/apps/cli/src/commands/orgs/list/list.handler.ts +++ b/apps/cli/src/commands/orgs/list/list.handler.ts @@ -42,7 +42,7 @@ export const orgsList = Effect.fn("orgs.list")(function* (_flags: OrgsListFlags) Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts index a63ae60692..800524b161 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -54,7 +54,7 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function unmarshalMessage: (description) => `failed to unmarshal delete response: ${description}`, }).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/postgres-config/get/get.handler.ts b/apps/cli/src/commands/postgres-config/get/get.handler.ts index 96e52515af..db64d5f380 100644 --- a/apps/cli/src/commands/postgres-config/get/get.handler.ts +++ b/apps/cli/src/commands/postgres-config/get/get.handler.ts @@ -27,7 +27,7 @@ export const postgresConfigGet = Effect.fn("postgres-config.get")(function* ( const config = yield* fetchCurrentPostgresConfig(ref).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; yield* writePostgresConfigOutput(config); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/postgres-config/update/update.handler.ts b/apps/cli/src/commands/postgres-config/update/update.handler.ts index 32d96b42fc..b9f11be4d9 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -71,7 +71,7 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function unmarshalMessage: (description) => `failed to unmarshal update response: ${description}`, }).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts index 2bf078e0c7..b5767a9814 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts @@ -66,7 +66,7 @@ export const projectsApiKeys = Effect.fn("projects.api-keys")(function* ( const keys: ApiKeys = yield* getProjectApiKeys(ref, flags.reveal).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/projects/delete/delete.handler.ts b/apps/cli/src/commands/projects/delete/delete.handler.ts index 63444cf179..f6292a615e 100644 --- a/apps/cli/src/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/commands/projects/delete/delete.handler.ts @@ -101,7 +101,7 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro }), ), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; // No per-ref keyring credential delete: the access token is stored under the profile // name, not the ref, so there is nothing to remove here. diff --git a/apps/cli/src/commands/projects/list/list.handler.ts b/apps/cli/src/commands/projects/list/list.handler.ts index fd1611ed9d..0dd0831a48 100644 --- a/apps/cli/src/commands/projects/list/list.handler.ts +++ b/apps/cli/src/commands/projects/list/list.handler.ts @@ -118,7 +118,7 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec decode: true, }); } - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // Prints the not-linked message to stderr but still renders the table below. if (Option.isNone(linkedRef)) { diff --git a/apps/cli/src/commands/secrets/list/list.handler.ts b/apps/cli/src/commands/secrets/list/list.handler.ts index 225202e8c3..c148a71acc 100644 --- a/apps/cli/src/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/commands/secrets/list/list.handler.ts @@ -67,7 +67,7 @@ export const secretsList = Effect.fn("secrets.list")(function* (flags: SecretsLi Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const sorted = sortSecrets(response); const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index 26df74dbdd..690690bed3 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -285,7 +285,7 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF Effect.tapError(() => setting?.fail() ?? Effect.void), Effect.catch(mapSetError), ); - yield* setting?.clear() ?? Effect.void; + yield* setting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success("Finished supabase secrets set.", { diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index bf411983f7..e39fe9d3cc 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -80,7 +80,7 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets Effect.tapError(() => unsetting?.fail() ?? Effect.void), Effect.catch(mapUnsetError), ); - yield* unsetting?.clear() ?? Effect.void; + yield* unsetting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success("Finished supabase secrets unset.", { diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index b77d959c3f..9d1b3233e7 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -185,7 +185,7 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // Exposes the full payload (id, name, owner, ... alongside content.sql) // for scripted callers — see SIDE_EFFECTS.md; matches the shape diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index 29a97a74bb..0f56049338 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -184,7 +184,7 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const parsed = parseSnippetsResponse(rawBody); const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts index 293b53dec3..0bf9a7f158 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts @@ -49,7 +49,7 @@ export const sslEnforcementGet = Effect.fn("ssl-enforcement.get")(function* ( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts index 5dac34f54a..cd92a3f378 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts @@ -66,7 +66,7 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index d4584ebc80..39519f7a1d 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -298,7 +298,7 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { } const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/sso/list/list.handler.ts b/apps/cli/src/commands/sso/list/list.handler.ts index 4a8217318e..06e9e826e7 100644 --- a/apps/cli/src/commands/sso/list/list.handler.ts +++ b/apps/cli/src/commands/sso/list/list.handler.ts @@ -75,7 +75,7 @@ export const ssoList = Effect.fn("sso.list")(function* (flags: SsoListFlags) { Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch((cause) => handleListError(ref, cause)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); const payload = { providers: response.items }; diff --git a/apps/cli/src/commands/sso/remove/remove.handler.ts b/apps/cli/src/commands/sso/remove/remove.handler.ts index 2f553d65ca..d35d06df9b 100644 --- a/apps/cli/src/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/commands/sso/remove/remove.handler.ts @@ -76,7 +76,7 @@ export const ssoRemove = Effect.fn("sso.remove")(function* (flags: SsoRemoveFlag Effect.tapError(() => removing?.fail() ?? Effect.void), Effect.catch((cause) => handleRemoveError(ref, providerId, cause)), ); - yield* removing?.clear() ?? Effect.void; + yield* removing?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/sso/show/show.handler.ts b/apps/cli/src/commands/sso/show/show.handler.ts index e56906f563..268af5b796 100644 --- a/apps/cli/src/commands/sso/show/show.handler.ts +++ b/apps/cli/src/commands/sso/show/show.handler.ts @@ -62,7 +62,7 @@ export const ssoShow = Effect.fn("sso.show")(function* (flags: SsoShowFlags) { Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch((cause) => handleShowError(providerId, cause)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // `--metadata` short-circuits regardless of `--output`. if (flags.metadata) { diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 51cf524d22..6d9f91b37b 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -484,7 +484,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag } const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts index b652353041..c8a860db7a 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -87,7 +87,7 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( }), ), ); - yield* activating?.clear() ?? Effect.void; + yield* activating?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts index cd58a72015..4a3f176363 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -91,7 +91,7 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch }), ), ); - yield* checking?.clear() ?? Effect.void; + yield* checking?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts index e7113972af..a3c012d68c 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts @@ -40,7 +40,7 @@ export const vanitySubdomainsDelete = Effect.fn("vanity-subdomains.delete")(func Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; // `--output` is ignored entirely (stderr-only success). We still read // the legacy flag so that an explicit --output suppresses the TS json/stream-json diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts index bd20347120..26974c69f7 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts @@ -55,7 +55,7 @@ export const vanitySubdomainsGet = Effect.fn("vanity-subdomains.get")(function* Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapGetError)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/whoami/whoami.handler.ts b/apps/cli/src/commands/whoami/whoami.handler.ts index bab4125ba7..097240084a 100644 --- a/apps/cli/src/commands/whoami/whoami.handler.ts +++ b/apps/cli/src/commands/whoami/whoami.handler.ts @@ -64,7 +64,7 @@ export const whoami = Effect.fn("whoami")(function* (_flags: WhoamiFlags) { Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapProfileError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; if (output.format !== "text") { yield* emitMachineProfile(profile); diff --git a/apps/cli/src/output/quiet-progress-text-output.layer.ts b/apps/cli/src/output/quiet-progress-text-output.layer.ts index 0d1da13fe5..3e867e3653 100644 --- a/apps/cli/src/output/quiet-progress-text-output.layer.ts +++ b/apps/cli/src/output/quiet-progress-text-output.layer.ts @@ -23,7 +23,7 @@ export const quietProgressTextOutputLayer = Layer.effect( fail: () => Effect.void, info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), progress: () => Effect.succeed({ diff --git a/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts b/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts index e1f39746c4..7cf78c14ba 100644 --- a/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts +++ b/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { beforeEach, vi } from "vitest"; import { Effect, Layer, Stdio } from "effect"; +import { TestClock } from "effect/testing"; import { mockTty } from "../../tests/helpers/mocks.ts"; import { Output } from "../shared/output/output.service.ts"; @@ -58,7 +59,6 @@ vi.mock("@clack/prompts", () => ({ beforeEach(() => { vi.resetAllMocks(); - vi.useRealTimers(); mockClack.isCancel.mockReturnValue(false); mockClack.spinnerFactory.mockReturnValue(mockClack.spinnerHandle); }); @@ -70,13 +70,12 @@ describe("quietProgressTextOutputLayer", () => { it.effect("never starts a spinner, even after the spinner delay elapses", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Fetching branches..."); yield* task.message("Still fetching..."); // TASK_SPINNER_DELAY_MS is 200ms; the text layer would show a spinner by now. - vi.advanceTimersByTime(500); - yield* task.clear(); + yield* TestClock.adjust(500); + yield* task.clear; expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.spinnerHandle.start).not.toHaveBeenCalled(); diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index bd050ca0e6..5e26e0d017 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -48,7 +48,7 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { fail: (_nextMessage?: string) => Effect.void, info: (_nextMessage?: string) => Effect.void, cancel: (_nextMessage?: string) => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), result: (_data: unknown) => Effect.void, success: (_message: string, _data?: Record) => Effect.void, @@ -80,7 +80,7 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { describe("withJsonErrorHandling", () => { describe("text format", () => { - it.live("re-raises the original error in text format", () => { + it.effect("re-raises the original error in text format", () => { const processControl = mockProcessControl(); return Effect.gen(function* () { const out = mockOutput("text"); @@ -106,7 +106,7 @@ describe("withJsonErrorHandling", () => { }); describe("json format", () => { - it.live("calls output.fail() with structured error and sets process.exitCode", () => { + it.effect("calls output.fail() with structured error and sets process.exitCode", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -125,10 +125,10 @@ describe("withJsonErrorHandling", () => { suggestion: "try again", }); expect(processControl.exitCode).toBe(1); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("includes detail and suggestion when present on error", () => { + it.effect("includes detail and suggestion when present on error", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -142,10 +142,10 @@ describe("withJsonErrorHandling", () => { detail: "in-depth explanation", suggestion: "do this instead", }); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("omits detail and suggestion when absent on error", () => { + it.effect("omits detail and suggestion when absent on error", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -157,10 +157,10 @@ describe("withJsonErrorHandling", () => { expect(call.message).toBe("minimal error"); expect("detail" in call).toBe(false); expect("suggestion" in call).toBe(false); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("uses UnknownError code when error has no _tag", () => { + it.effect("uses UnknownError code when error has no _tag", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -169,10 +169,10 @@ describe("withJsonErrorHandling", () => { expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]?.code).toBe("UnknownError"); expect(out.failCalls[0]?.message).toBe("plain error message"); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("sets the exact exit code for a GoChildExitError, not a generic 1", () => { + it.effect("sets the exact exit code for a GoChildExitError, not a generic 1", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -184,7 +184,7 @@ describe("withJsonErrorHandling", () => { expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]?.code).toBe("GoChildExitError"); expect(processControl.exitCode).toBe(130); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); }); }); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 5d6a304a29..5440a67521 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -14,7 +14,7 @@ import { text, } from "@clack/prompts"; import { styleText } from "node:util"; -import { Effect, Layer, Option, Stdio, Stream } from "effect"; +import { DateTime, Effect, Fiber, Layer, Option, Schema, Stdio, Stream } from "effect"; import { Tty } from "../runtime/tty.service.ts"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; @@ -24,6 +24,13 @@ import type { OutputFormat, StreamEvent } from "./types.ts"; const TASK_SPINNER_DELAY_MS = 200; +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = (value: unknown) => + encodeJsonString(value).pipe( + Effect.mapError((error) => new TypeError(error.message, { cause: error })), + Effect.orDie, + ); + // Reads the opt-in `MachineErrorContext` cell, if any command in this run // provided it — see that service's doc comment for the envelope contract. const readMachineErrorContext = Effect.fnUntraced(function* () { @@ -64,6 +71,7 @@ export const textOutputLayer = Layer.effect( Effect.gen(function* () { const tty = yield* Tty; const write = stdioWriter(yield* Stdio.Stdio); + const scope = yield* Effect.scope; const DEFAULT_AUTOCOMPLETE_THRESHOLD = 10; const buildSelectOptions = ( @@ -267,37 +275,16 @@ export const textOutputLayer = Layer.effect( warn: (message: string) => Effect.sync(() => logAround(log.warn, message)), error: (message: string) => Effect.sync(() => logAround(log.error, message)), event: (event: StreamEvent) => - Effect.sync(() => - logAround( - log.info, - event.type === "log-entry" ? `[${event.service}] ${event.line}` : JSON.stringify(event), - ), - ), + (event.type === "log-entry" + ? Effect.succeed(`[${event.service}] ${event.line}`) + : encodeJson(event) + ).pipe(Effect.flatMap((message) => Effect.sync(() => logAround(log.info, message)))), task: (message: string) => - Effect.sync(() => { + Effect.gen(function* () { let shown = false; let settled = false; let currentMessage = message; let shownSpinner: ShownSpinner | undefined; - let timeout: ReturnType | undefined; - - const cancelPendingStart = () => { - if (timeout !== undefined) { - clearTimeout(timeout); - timeout = undefined; - } - }; - - const finish = (render: () => void) => { - settled = true; - cancelPendingStart(); - const settle = () => { - render(); - if (activeSpinner === shownSpinner) activeSpinner = undefined; - }; - if (shownSpinner !== undefined && shownSpinner.pauses > 0) shownSpinner.settle = settle; - else settle(); - }; // clack's spinner writes cursor/animation escape codes, so non-TTY stdout // gets plain progress lines instead. @@ -317,11 +304,25 @@ export const textOutputLayer = Layer.effect( activeSpinner = shownSpinner; }; - timeout = setTimeout(() => { - timeout = undefined; + const pendingStart = yield* Effect.sync(() => { if (unpausedWrites > 0) showWhenIdle = show; else show(); - }, TASK_SPINNER_DELAY_MS); + }).pipe( + Effect.delay(TASK_SPINNER_DELAY_MS), + Effect.forkIn(scope, { startImmediately: true }), + ); + + const finish = (render: () => void) => + Effect.sync(() => { + settled = true; + const settle = () => { + render(); + if (activeSpinner === shownSpinner) activeSpinner = undefined; + }; + if (shownSpinner !== undefined && shownSpinner.pauses > 0) + shownSpinner.settle = settle; + else settle(); + }).pipe(Effect.andThen(Fiber.interrupt(pendingStart))); return { message: (nextMessage: string) => @@ -341,60 +342,49 @@ export const textOutputLayer = Layer.effect( } }), succeed: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - shownSpinner?.handle.stop(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - log.success(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.stop(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + log.success(nextMessage); + } + }), fail: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - shownSpinner?.handle.error(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - log.error(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.error(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + log.error(nextMessage); + } + }), info: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - shownSpinner?.handle.clear(); - } - if (nextMessage !== undefined) { - log.info(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.clear(); + } + if (nextMessage !== undefined) { + log.info(nextMessage); + } + }), cancel: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - shownSpinner?.handle.cancel(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - cancel(nextMessage); - } - }), - ), - clear: () => - Effect.sync(() => - finish(() => { - if (shown) { - shownSpinner?.handle.clear(); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.cancel(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + cancel(nextMessage); + } + }), + clear: finish(() => { + if (shown) { + shownSpinner?.handle.clear(); + } + }), }; }), promptText: ( @@ -514,7 +504,8 @@ export const jsonOutputLayer = Layer.effect( info: (message: string) => writeStderr(`${message}\n`), warn: (message: string) => writeStderr(`${message}\n`), error: (message: string) => writeStderr(`${message}\n`), - event: (event: StreamEvent) => writeStderr(`${JSON.stringify(event)}\n`), + event: (event: StreamEvent) => + encodeJson(event).pipe(Effect.flatMap((json) => writeStderr(`${json}\n`))), task: (message: string) => Effect.sync(() => ({ message: (nextMessage: string) => writeStderr(`[task] ${nextMessage}\n`), @@ -526,7 +517,7 @@ export const jsonOutputLayer = Layer.effect( nextMessage ? writeStderr(`${nextMessage}\n`) : Effect.void, cancel: (nextMessage?: string) => nextMessage ? writeStderr(`[task] cancelled: ${nextMessage}\n`) : Effect.void, - clear: () => Effect.void, + clear: Effect.void, })).pipe(Effect.tap(() => writeStderr(`[task] start: ${message}\n`))), promptText: () => nonInteractive("prompt for input"), promptPassword: () => nonInteractive("prompt for password"), @@ -553,7 +544,8 @@ export const jsonOutputLayer = Layer.effect( const extra = yield* readMachineErrorContext(); // `extra` spreads first so the envelope's own `_tag`/`error` can't // be clobbered by a same-named context field. - yield* writeStdout(JSON.stringify({ ...extra, _tag: "Error", error: err }) + "\n"); + const json = yield* encodeJson({ ...extra, _tag: "Error", error: err }); + yield* writeStdout(json + "\n"); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), @@ -567,15 +559,13 @@ export const streamJsonOutputLayer = Layer.effect( Effect.gen(function* () { const write = stdioWriter(yield* Stdio.Stdio); const writeStdout = (s: string) => write(s, "stdout"); - const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => { - const event: StreamEvent = { - type: "log", - level, - message, - timestamp: new Date().toISOString(), - }; - return writeStdout(JSON.stringify(event) + "\n"); - }; + const emitEvent = (event: (timestamp: string) => StreamEvent, extra: object = {}) => + DateTime.now.pipe( + Effect.flatMap((now) => encodeJson({ ...extra, ...event(DateTime.formatIso(now)) })), + Effect.flatMap((json) => writeStdout(json + "\n")), + ); + const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => + emitEvent((timestamp) => ({ type: "log", level, message, timestamp })); const nonInteractive = (action: string) => Effect.fail( @@ -584,14 +574,8 @@ export const streamJsonOutputLayer = Layer.effect( suggestion: "Provide all required values via flags", }), ); - const result = (data: unknown) => { - const event: StreamEvent = { - type: "result", - data, - timestamp: new Date().toISOString(), - }; - return writeStdout(`${JSON.stringify(event)}\n`); - }; + const result = (data: unknown) => + emitEvent((timestamp) => ({ type: "result", data, timestamp })); return Output.of({ format: "stream-json" as const, @@ -601,7 +585,8 @@ export const streamJsonOutputLayer = Layer.effect( info: (message: string) => emitLog("info", message), warn: (message: string) => emitLog("warn", message), error: (message: string) => emitLog("error", message), - event: (event: StreamEvent) => writeStdout(JSON.stringify(event) + "\n"), + event: (event: StreamEvent) => + encodeJson(event).pipe(Effect.flatMap((json) => writeStdout(json + "\n"))), task: (message: string) => Effect.sync(() => ({ message: (nextMessage: string) => emitLog("info", nextMessage), @@ -609,7 +594,7 @@ export const streamJsonOutputLayer = Layer.effect( fail: (nextMessage?: string) => emitLog("error", nextMessage ?? "Task failed."), info: (nextMessage?: string) => emitLog("info", nextMessage ?? "Task completed."), cancel: (nextMessage?: string) => emitLog("warn", nextMessage ?? "Task cancelled."), - clear: () => Effect.void, + clear: Effect.void, })).pipe(Effect.tap(() => emitLog("info", message))), promptText: () => nonInteractive("prompt for input"), promptPassword: () => nonInteractive("prompt for password"), @@ -620,15 +605,15 @@ export const streamJsonOutputLayer = Layer.effect( Effect.sync(() => { let current = 0; const emit = (status: "start" | "active" | "done", message: string) => { - const event: StreamEvent = { + const at = current; + return emitEvent((timestamp) => ({ type: "progress", status, - current, + current: at, max: opts.max, message, - timestamp: new Date().toISOString(), - }; - return writeStdout(JSON.stringify(event) + "\n"); + timestamp, + })); }; return { @@ -646,14 +631,9 @@ export const streamJsonOutputLayer = Layer.effect( fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); - const event: StreamEvent = { - type: "error", - error: err, - timestamp: new Date().toISOString(), - }; // `extra` spreads first so the event's own `type`/`error`/`timestamp` // can't be clobbered by a same-named context field. - yield* writeStdout(JSON.stringify({ ...extra, ...event }) + "\n"); + yield* emitEvent((timestamp) => ({ type: "error", error: err, timestamp }), extra); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 9dc07d7a01..24a1e4b23a 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { afterEach, beforeEach, vi } from "vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Sink, Stdio, Stream } from "effect"; +import { beforeEach, vi } from "vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Sink, Stdio, Stream } from "effect"; +import { TestClock } from "effect/testing"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; @@ -63,14 +64,13 @@ vi.mock("@clack/prompts", () => ({ beforeEach(() => { vi.resetAllMocks(); - vi.useRealTimers(); mockClack.isCancel.mockReturnValue(false); mockClack.spinnerFactory.mockReturnValue(mockClack.spinnerHandle); }); -afterEach(() => { - vi.useRealTimers(); -}); +const decodeJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); function mockStdio() { const stdout: string[] = []; @@ -112,11 +112,10 @@ describe("Output", () => { it.effect("task uses clack spinner and can resolve into info", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.message("Still loading..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* task.info("Loaded organizations."); expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(1); @@ -129,11 +128,10 @@ describe("Output", () => { it.effect("task skips the spinner when it completes quickly", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.succeed("Loaded organizations."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.spinnerHandle.start).not.toHaveBeenCalled(); @@ -145,11 +143,10 @@ describe("Output", () => { "task keeps raw multiline formatting when it completes before the spinner shows", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.succeed("- name: Supabase\n- name: Supabase Dev"); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.log.success).toHaveBeenCalledWith( @@ -160,10 +157,9 @@ describe("Output", () => { it.effect("task prefixes continuation lines for multiline completions", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* task.succeed("- name: Supabase\n- name: Supabase Dev"); expect(mockClack.spinnerHandle.stop).toHaveBeenCalledWith( @@ -338,12 +334,23 @@ describe("Output", () => { }).pipe(Effect.provide(sunk)); }); + it.effect("never shows the spinner of a task left pending when the layer closes", () => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const out = yield* Output; + yield* out.task("Loading organizations..."); + }).pipe(Effect.provide(layer, { local: true })); + yield* TestClock.adjust(200); + + expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); + }), + ); + it.effect("pauses and resumes the task spinner around a log while it is shown", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* out.warn("no files matched pattern: missing.sql"); @@ -383,10 +390,9 @@ describe("Output", () => { Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), ); return Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); mockClack.spinnerHandle.clear.mockImplementation(() => order.push("clear")); mockClack.spinnerHandle.start.mockImplementation((msg?: string) => @@ -430,11 +436,9 @@ describe("Output", () => { Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), ); yield* Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); - vi.useRealTimers(); + yield* TestClock.adjust(200); const firstWrite = yield* Effect.forkChild(out.raw(first.name, "stderr")); yield* Deferred.await(first.entered); @@ -477,12 +481,11 @@ describe("Output", () => { ); yield* Effect.gen(function* () { const out = yield* Output; - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); yield* out.task("Loading organizations..."); const write = yield* Effect.forkChild(out.raw("slow\n", "stderr")); yield* Deferred.await(entered); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); yield* Deferred.succeed(release, undefined); @@ -494,7 +497,6 @@ describe("Output", () => { it.effect("settles the task through the spinner resumed after a log", () => Effect.gen(function* () { - vi.useFakeTimers(); const handle = () => ({ start: vi.fn(), stop: vi.fn(), @@ -509,7 +511,7 @@ describe("Output", () => { mockClack.spinnerFactory.mockReturnValueOnce(first).mockReturnValueOnce(resumed); const out = yield* Output; const task = yield* out.task("Starting local Supabase stack..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* out.info("Seeding globals from roles.sql..."); yield* task.succeed("Stack is ready."); @@ -523,11 +525,10 @@ describe("Output", () => { it.effect("clears a shown task spinner before rendering a command failure", () => Effect.gen(function* () { - vi.useFakeTimers(); const writes = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const out = yield* Output; yield* out.task("Starting local Supabase stack..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* out.fail({ code: "E_TEST", message: "no database", suggestion: "retry" }); const [clear] = mockClack.spinnerHandle.clear.mock.invocationCallOrder; @@ -743,10 +744,9 @@ describe("Output", () => { it.effect("task logs each distinct progress message as a plain line instead of a spinner", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* task.message("Loading projects..."); yield* task.message("Loading projects..."); yield* task.succeed("Loaded organizations."); @@ -916,7 +916,7 @@ describe("Output", () => { const out = yield* Output; yield* out.success("ok", { id: 42 }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ id: 42, message: "ok" }); }).pipe(Effect.provide(layer)); }); @@ -938,7 +938,7 @@ describe("Output", () => { const out = yield* Output; yield* out.fail({ code: "E_TEST", message: "failed", detail: "details" }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed", detail: "details" }, @@ -962,7 +962,7 @@ describe("Output", () => { yield* context.set({ linked_project: { project_ref: "abc" } }); yield* out.fail({ code: "E_TEST", message: "failed" }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -978,7 +978,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_TEST", message: "failed" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -1001,7 +1001,7 @@ describe("Output", () => { const context = yield* MachineErrorContext; yield* context.set({ _tag: "Hacked", error: "Hacked", safe_field: "ok" }); yield* out.fail({ code: "E_TEST", message: "failed" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -1029,7 +1029,7 @@ describe("Output", () => { const out = yield* Output; yield* out.intro("Starting up"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("Starting up"); @@ -1044,7 +1044,7 @@ describe("Output", () => { const out = yield* Output; yield* out.outro("All done"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("All done"); @@ -1058,12 +1058,9 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.info("stream info"); - expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); - expect(parsed.type).toBe("log"); - expect(parsed.level).toBe("info"); - expect(parsed.message).toBe("stream info"); - expect(parsed.timestamp).toBeDefined(); + expect(mock.stdout).toEqual([ + '{"type":"log","level":"info","message":"stream info","timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); }).pipe(Effect.provide(layer)); }); @@ -1073,7 +1070,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.warn("stream warn"); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("warn"); expect(parsed.message).toBe("stream warn"); @@ -1086,7 +1083,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.error("stream error"); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("error"); expect(parsed.message).toBe("stream error"); @@ -1106,7 +1103,7 @@ describe("Output", () => { line: "checkpoint complete", source: "live", }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ type: "log-entry", timestamp: "2026-03-11T00:00:00.000Z", @@ -1127,8 +1124,8 @@ describe("Output", () => { yield* task.succeed("Loaded organizations."); expect(mock.stdout).toHaveLength(2); - const started = JSON.parse(mock.stdout[0]!); - const finished = JSON.parse(mock.stdout[1]!); + const started = yield* decodeJson(mock.stdout[0]!); + const finished = yield* decodeJson(mock.stdout[1]!); expect(started).toEqual( expect.objectContaining({ type: "log", @@ -1200,13 +1197,30 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); + it.effect("progress emits NDJSON progress events", () => { + const mock = mockStdio(); + const layer = streamJsonOutputLayer.pipe(Layer.provide(mock.layer)); + return Effect.gen(function* () { + const out = yield* Output; + const bar = yield* out.progress({ max: 3 }); + yield* bar.start("Working..."); + yield* bar.advance(2, "Halfway"); + yield* bar.stop("Done."); + expect(mock.stdout).toEqual([ + '{"type":"progress","status":"start","current":0,"max":3,"message":"Working...","timestamp":"1970-01-01T00:00:00.000Z"}\n', + '{"type":"progress","status":"active","current":2,"max":3,"message":"Halfway","timestamp":"1970-01-01T00:00:00.000Z"}\n', + '{"type":"progress","status":"done","current":2,"max":3,"message":"Done.","timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect("success emits result event", () => { const mock = mockStdio(); const layer = streamJsonOutputLayer.pipe(Layer.provide(mock.layer)); return Effect.gen(function* () { const out = yield* Output; yield* out.success("done", { key: "value" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("result"); expect(parsed.data).toEqual({ key: "value", message: "done" }); expect(parsed.timestamp).toBeDefined(); @@ -1219,13 +1233,20 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.result({ id: 42 }); - const parsed = JSON.parse(mock.stdout[0]!); - expect(parsed).toEqual({ - type: "result", - data: { id: 42 }, - timestamp: expect.any(String), - }); - expect(parsed.data).not.toHaveProperty("message"); + expect(mock.stdout).toEqual([ + '{"type":"result","data":{"id":42},"timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("result dies with a TypeError for an unserializable payload", () => { + const mock = mockStdio(); + const layer = streamJsonOutputLayer.pipe(Layer.provide(mock.layer)); + return Effect.gen(function* () { + const out = yield* Output; + const exit = yield* out.result({ id: 42n }).pipe(Effect.exit); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf(TypeError); + expect(mock.stdout).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -1235,14 +1256,9 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_FAIL", message: "boom", suggestion: "try again" }); - const parsed = JSON.parse(mock.stdout[0]!); - expect(parsed.type).toBe("error"); - expect(parsed.error).toEqual({ - code: "E_FAIL", - message: "boom", - suggestion: "try again", - }); - expect(parsed.timestamp).toBeDefined(); + expect(mock.stdout).toEqual([ + '{"type":"error","error":{"code":"E_FAIL","message":"boom","suggestion":"try again"},"timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); }).pipe(Effect.provide(layer)); }); @@ -1257,7 +1273,7 @@ describe("Output", () => { const context = yield* MachineErrorContext; yield* context.set({ linked_project: { project_ref: "abc" } }); yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", message: "boom" }); expect(parsed.linked_project).toEqual({ project_ref: "abc" }); @@ -1276,7 +1292,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(Object.keys(parsed).sort()).toEqual(["error", "timestamp", "type"]); }).pipe(Effect.provide(layer)); }); @@ -1300,7 +1316,7 @@ describe("Output", () => { safe_field: "ok", }); yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", message: "boom" }); expect(typeof parsed.timestamp).toBe("string"); diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index c9a2435d77..eb0668a11d 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -10,7 +10,7 @@ export interface OutputTask { readonly fail: (message?: string) => Effect.Effect; readonly info: (message?: string) => Effect.Effect; readonly cancel: (message?: string) => Effect.Effect; - readonly clear: () => Effect.Effect; + readonly clear: Effect.Effect; } interface OutputSelectOption { diff --git a/apps/cli/src/shared/output/table.unit.test.ts b/apps/cli/src/shared/output/table.unit.test.ts index 389d2aa46e..ee4c846474 100644 --- a/apps/cli/src/shared/output/table.unit.test.ts +++ b/apps/cli/src/shared/output/table.unit.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { columnWidths, formatTableRow, outputTable } from "./table.ts"; @@ -32,36 +32,34 @@ describe("formatTableRow", () => { }); describe("outputTable", () => { - it("emits a header row then one info message per data item", async () => { + it.effect("emits a header row then one info message per data item", () => { const out = mockOutput(); - await Effect.runPromise( - outputTable(["ID", "NAME"], [{ id: "1", name: "main" }], (r) => [r.id, r.name]).pipe( - Effect.provide(out.layer), - ), - ); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos).toEqual(["ID NAME", "1 main"]); + return Effect.gen(function* () { + yield* outputTable(["ID", "NAME"], [{ id: "1", name: "main" }], (r) => [r.id, r.name]); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos).toEqual(["ID NAME", "1 main"]); + }).pipe(Effect.provide(out.layer)); }); - it("uses header width when wider than any cell", async () => { + it.effect("uses header width when wider than any cell", () => { const out = mockOutput(); - await Effect.runPromise( - outputTable(["STATUS"], [{ s: "OK" }], (r) => [r.s]).pipe(Effect.provide(out.layer)), - ); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos[0]).toBe("STATUS"); - expect(infos[1]).toBe("OK "); + return Effect.gen(function* () { + yield* outputTable(["STATUS"], [{ s: "OK" }], (r) => [r.s]); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos[0]).toBe("STATUS"); + expect(infos[1]).toBe("OK "); + }).pipe(Effect.provide(out.layer)); }); - it("calls formatRow with cells, widths, and original item to produce row string", async () => { + it.effect("calls formatRow with cells, widths, and original item to produce row string", () => { const out = mockOutput(); const captured: Array<{ cells: ReadonlyArray; widths: ReadonlyArray; item: { name: string }; }> = []; - await Effect.runPromise( - outputTable( + return Effect.gen(function* () { + yield* outputTable( ["NAME"], [{ name: "alice" }], (r) => [r.name], @@ -69,13 +67,13 @@ describe("outputTable", () => { captured.push({ cells, widths, item }); return formatTableRow(cells, widths) + " [custom]"; }, - ).pipe(Effect.provide(out.layer)), - ); - expect(captured).toHaveLength(1); - expect(captured[0]!.cells).toEqual(["alice"]); - expect(captured[0]!.widths).toEqual([5]); - expect(captured[0]!.item).toEqual({ name: "alice" }); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos[1]).toBe("alice [custom]"); + ); + expect(captured).toHaveLength(1); + expect(captured[0]!.cells).toEqual(["alice"]); + expect(captured[0]!.widths).toEqual([5]); + expect(captured[0]!.item).toEqual({ name: "alice" }); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos[1]).toBe("alice [custom]"); + }).pipe(Effect.provide(out.layer)); }); }); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 9d789e5be5..4a00600a27 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -285,7 +285,7 @@ export function mockOutput( messages.push({ type: "warn", message: nextMessage }); } }), - clear: () => Effect.void, + clear: Effect.void, }; }), event: (event) => From 26dc09a3245055d71352888d1d8f93beceedafc3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 15:07:34 +0000 Subject: [PATCH 61/71] feat(cli): report stack MCP at the API URL and include connection env in JSON (CLI-2546) (#6914) ## Summary Follow-up to #6894 and #6898 (which serves Studio's MCP server at `/mcp` on the shared API listener). With the stack backend, `start` and `status` now report connection details the way legacy `start`/`status` do: - `MCP_URL` is `/mcp` (for example `http://127.0.0.1:54321/mcp`), present when the API listener and a Studio member exist. `endpoints` no longer carries a synthetic `studio.mcp` entry. - `DB_URL` is a plain `postgresql://postgres:@:/postgres` using the `postgres` role, built by the same helper `db` commands connect with, without the internal `connect_timeout` parameter. - The exported variables are `API_URL`, `REST_URL`, `FUNCTIONS_URL`, `DB_URL`, `STUDIO_URL`, `MCP_URL`, `MAILPIT_URL`, `INBUCKET_URL` (a deprecated alias of `MAILPIT_URL`, as in legacy), `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY` and `SERVICE_ROLE_KEY`. The never-populated `S3_PROTOCOL_*` names are no longer accepted by `--override-name`, which now names a rejected entry and lists the valid variables. - The `status --env` hint printed by `start` is exercised through the real command grammar, including named stacks and `--workdir`. **Deliberate contract change:** `start` JSON (every success path) and `status` JSON now include an `env` object with those values, including the local keys and the password-bearing `DB_URL`. It is the same map `status --env --output-format json` returns. Previously the docs promised that only `status --env` revealed credentials; this matches legacy `start`/`status`, whose JSON output includes the local keys. Plain `status` JSON `env` comes from saved bindings and still reports when credentials cannot be read, while `--env` requires a reachable owner and a running primary database. --- apps/cli/docs/stack-commands.md | 51 ++- apps/cli/src/command-internal/postgres-url.ts | 15 + .../command-internal/stack-local-database.ts | 9 +- .../experimental/stack/stack-summary.ts | 64 ++-- .../experimental/stack/start/SIDE_EFFECTS.md | 11 +- .../start-export-pointer.integration.test.ts | 312 ++++++++++++++++++ .../stack/start/start-summary.format.ts | 23 ++ .../stack/start/start.e2e.test.ts | 11 +- .../experimental/stack/start/start.handler.ts | 34 +- .../stack/start/start.integration.test.ts | 82 ++++- .../experimental/stack/status/SIDE_EFFECTS.md | 36 +- .../experimental/stack/status/status.env.ts | 67 ++-- .../stack/status/status.env.unit.test.ts | 74 ++++- .../stack/status/status.handler.ts | 32 +- .../stack/status/status.integration.test.ts | 230 +++++++++++-- 15 files changed, 884 insertions(+), 167 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start-summary.format.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 059d6702c0..dd8cbdd896 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -48,7 +48,8 @@ supabase stack prepare --capability rest --capability auth --output-format json When the stack is ready, `supabase stack start` prints the API, REST, Functions, database, Studio, MCP, and Mailpit URLs, the local publishable and secret keys, each service's state, and the runtime. Ports are assigned per project, so read the MCP URL from this output rather -than assuming a default port. +than assuming a default port. MCP is served at `/mcp`, present only when the shared API +listener is up and Studio is a composition member with an HTTP endpoint. With `--output-format json`, start returns: ```json @@ -61,22 +62,31 @@ With `--output-format json`, start returns: "address": "127.0.0.1", "port": 54322, "url": "tcp://127.0.0.1:54322" - }, - "studio.mcp": { - "protocol": "http", - "address": "127.0.0.1", - "port": 54323, - "url": "http://127.0.0.1:54323/api/mcp" } }, "lazy_services": ["rest", "auth", "studio"], + "env": { + "API_URL": "http://127.0.0.1:54321", + "DB_URL": "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + "STUDIO_URL": "http://127.0.0.1:54323", + "MCP_URL": "http://127.0.0.1:54321/mcp", + "MAILPIT_URL": "http://127.0.0.1:54324", + "PUBLISHABLE_KEY": "sb_publishable_...", + "SECRET_KEY": "sb_secret_...", + "ANON_KEY": "ey...", + "SERVICE_ROLE_KEY": "ey..." + }, "message": "" } ``` -`endpoints` uses the same `service.endpoint` keys as `stack status`, and `lazy_services` lists the -services that start on their first request. For the complete connection set including credentials, -use `supabase stack status --env --output-format json`. +`endpoints` uses the same `service.endpoint` keys as `stack status`, with no synthetic entries. +`lazy_services` lists the services that start on their first request. `env` is the same connection +map `supabase stack status --env` exports, present on every success path; `stack status` (without +`--env`) returns the same `env` key, degrading to whatever is available when credentials or the +owner are unreachable. Plain `status` JSON `env` comes from saved bindings and can list values for +stopped or sleeping members, while `--env` requires a reachable owner and a running primary +database. ## Exporting environment variables @@ -91,13 +101,20 @@ and credentials available from the observed composition; text mode emits dotenv JSON or stream-JSON mode emits a variable map. Values that are unavailable because a member is stopped or unhealthy are omitted. Add `--output-format text` for an explicit dotenv file regardless of automatic agent output detection; this is dotenv data, not a shell script, and values -are quoted so that sourcing the file performs no shell expansion. It also exports `MCP_URL`, -Studio's MCP endpoint, whose port is assigned per project. The human-readable output of `start` and -`status` shows the local publishable and secret keys and the database URL; their JSON results -never include credentials, so this export is the machine-readable source for them. `--override-name` -accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects -unknown variables, invalid names, and collisions. The DB-derived service-role JWT remains available -when Auth is disabled; unavailable service URLs and credentials are omitted. +are quoted so that sourcing the file performs no shell expansion. The exported variable set is +`API_URL`, `REST_URL`, `FUNCTIONS_URL`, `DB_URL`, `STUDIO_URL`, `MCP_URL`, `MAILPIT_URL`, +`PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, and `SERVICE_ROLE_KEY`; `REST_URL` and +`FUNCTIONS_URL` are `/rest/v1` and `/functions/v1`, `MCP_URL` is `/mcp`, +whose port is assigned per project, and `DB_URL` uses the `postgres` role with the saved database +password, URI-encoded, and no query string. `INBUCKET_URL` is also exported alongside +`MAILPIT_URL`, with the same value, as a deprecated alias. `start` and `status` text output shows +only the publishable and secret keys; `ANON_KEY` and `SERVICE_ROLE_KEY` appear only in JSON `env` +and `status --env`. `start` and `status` JSON/stream-JSON results include this same connection map +under `env`; `status --env` remains the dotenv/variable-map export, and `--override-name` only +applies there. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` +entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. The +DB-derived service-role JWT remains available when Auth is disabled; unavailable service URLs and +credentials are omitted. The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pretty`, `json`, `toml`, `yaml`, `table`, and `csv`. `--output-format text`, `json`, or `stream-json` replace them. diff --git a/apps/cli/src/command-internal/postgres-url.ts b/apps/cli/src/command-internal/postgres-url.ts index a489f8cd0a..02aa1b22ef 100644 --- a/apps/cli/src/command-internal/postgres-url.ts +++ b/apps/cli/src/command-internal/postgres-url.ts @@ -46,6 +46,21 @@ export interface PostgresUrlInput { readonly runtimeParams?: Readonly>; } +export interface UserFacingDatabaseUrlInput { + readonly host: string; + readonly port: number; + readonly password: string; +} + +/** + * Builds the `postgres`-role connection string shown to users (stack summary, `db url` + * commands), without the tool-only query parameters {@link toPostgresURL} adds. + */ +export function toUserFacingDatabaseUrl(conn: UserFacingDatabaseUrlInput): string { + const host = isIPv6Host(conn.host) ? `[${conn.host}]` : conn.host; + return `postgresql://postgres:${encodeURIComponent(conn.password)}@${host}:${conn.port}/postgres`; +} + export function toPostgresURL(conn: PostgresUrlInput): string { const timeout = conn.connectTimeoutSeconds !== undefined && conn.connectTimeoutSeconds > 0 diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index a3ab5899da..1dddca074e 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -6,6 +6,7 @@ import { ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; import { parseConnectionString } from "./db-config.parse.ts"; +import { toUserFacingDatabaseUrl } from "./postgres-url.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { currentStackBackend } from "./stack-backend.ts"; @@ -183,9 +184,11 @@ const stackLocalDatabaseUrl: Effect.Effect< return yield* notRunning("The local stack primary service is not a database."); if (endpoint === undefined) return yield* notRunning("The local stack database SQL endpoint is unavailable."); - const password = Redacted.value(status.config.config.databasePassword); - const host = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host; - return `postgresql://postgres:${encodeURIComponent(password)}@${host}:${endpoint.port}/postgres`; + return toUserFacingDatabaseUrl({ + host: endpoint.host, + port: endpoint.port, + password: Redacted.value(status.config.config.databasePassword), + }); }).pipe(Effect.scoped); export const stackLocalDatabaseConn: Effect.Effect< diff --git a/apps/cli/src/commands/experimental/stack/stack-summary.ts b/apps/cli/src/commands/experimental/stack/stack-summary.ts index 88bfe01b7c..04931ff5c2 100644 --- a/apps/cli/src/commands/experimental/stack/stack-summary.ts +++ b/apps/cli/src/commands/experimental/stack/stack-summary.ts @@ -6,7 +6,7 @@ import { type StackCredentials, } from "@supabase/stack/effect"; import { red } from "../../../command-internal/colors.ts"; -import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; +import { toUserFacingDatabaseUrl } from "../../../command-internal/postgres-url.ts"; import { renderStatusGroups, statusGroups, @@ -56,38 +56,27 @@ export const serviceState = (observation: Observation | undefined): StackService return observation.lifecycle; }; -// Studio serves MCP itself; the stack gateway has no `/mcp` route. -const mcpUrl = (studioUrl: string) => `${studioUrl}/api/mcp`; - -/** Reports endpoints keyed `service.endpoint`, plus `studio.mcp` when Studio has an HTTP endpoint. */ +/** Reports raw endpoint observations keyed `service.endpoint`. */ export const stackEndpoints = ( services: ReadonlyArray>, -) => { - const endpoints = Object.fromEntries( +) => + Object.fromEntries( services.flatMap(({ service, observation }) => Object.entries(endpointReports(observation)).map( ([name, endpoint]) => [`${service}.${name}`, endpoint] as const, ), ), ); - const studio = endpoints["studio.http"]; - return studio === undefined - ? endpoints - : { ...endpoints, "studio.mcp": { ...studio, url: mcpUrl(studio.url) } }; -}; const stackDatabaseUrl = (observation: Observation | undefined): string | undefined => { if (observation?.config.service !== "database") return undefined; const sql = observation.endpoints.find(({ name }) => name === "sql"); - return sql === undefined - ? undefined - : toPostgresURL({ - host: sql.host, - port: sql.port, - user: "supabase_admin", - password: Redacted.value(observation.config.config.databasePassword), - database: "postgres", - }); + if (sql === undefined) return undefined; + return toUserFacingDatabaseUrl({ + host: sql.host, + port: sql.port, + password: Redacted.value(observation.config.config.databasePassword), + }); }; /** Derives connection URLs; callers pass composition members only. */ @@ -119,12 +108,43 @@ export const stackConnections = ( ...(api === undefined ? {} : { api }), ...(rest === undefined ? {} : { rest }), ...(functions === undefined ? {} : { functions }), - ...(studio === undefined ? {} : { studio, mcp: mcpUrl(studio) }), + ...(studio === undefined ? {} : { studio }), + // The shared API listener serves `/mcp` only when Studio is a composition member with an + // HTTP endpoint. + ...(api !== undefined && studio !== undefined ? { mcp: `${api}/mcp` } : {}), ...(mailpit === undefined ? {} : { mailpit }), ...(database === undefined ? {} : { database }), }; }; +/** The `status --env` variable map; shared by `stack start`'s JSON `env` and `stack status`. */ +export const connectionEnv = ( + connections: StackConnections, + credentials: + | Pick + | undefined, +): Readonly> => { + const values: Record = {}; + if (connections.api !== undefined) values.API_URL = connections.api; + if (connections.rest !== undefined) values.REST_URL = connections.rest; + if (connections.functions !== undefined) values.FUNCTIONS_URL = connections.functions; + if (connections.database !== undefined) values.DB_URL = connections.database; + if (connections.studio !== undefined) values.STUDIO_URL = connections.studio; + if (connections.mcp !== undefined) values.MCP_URL = connections.mcp; + if (connections.mailpit !== undefined) { + values.MAILPIT_URL = connections.mailpit; + // Deprecated alias of `MAILPIT_URL`, kept for parity with legacy `status --env`. + values.INBUCKET_URL = connections.mailpit; + } + if (credentials !== undefined) { + values.PUBLISHABLE_KEY = credentials.publishableKey; + values.SECRET_KEY = credentials.secretKey; + values.ANON_KEY = credentials.anonKey; + values.SERVICE_ROLE_KEY = credentials.serviceRoleKey; + } + return values; +}; + const connectionValues = ( connections: StackConnections, credentials: Pick | undefined, diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 32343e87db..36d633c0f2 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -128,8 +128,9 @@ pointer to `supabase status --env` that repeats an explicit `--workdir` and any and warnings written while the spinner is shown appear on their own rows. JSON output returns the stack `id`, its saved `runtime`, `endpoints` keyed by service and endpoint -name (protocol, address, port, and URL, matching `stack status`, plus `studio.mcp` when Studio has -an HTTP endpoint), `lazy_services` listing members that start on their first request (empty with -`--eager`), and an empty message. See [`docs/stack-commands.md`](../../../../../docs/stack-commands.md) -for an example. Credentials are not part of the JSON result. Failures retain typed command errors -and package diagnostics. Telemetry state is flushed after success or failure. +name (protocol, address, port, and URL, matching `stack status`, with no synthetic entries), +`lazy_services` listing members that start on their first request (empty with `--eager`), `env` +(the same connection map `stack status --env` exports, present on every success path), and an +empty message. See [`docs/stack-commands.md`](../../../../../docs/stack-commands.md) for an +example. Failures retain typed command errors and package diagnostics. Telemetry state is flushed +after success or failure. diff --git a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts new file mode 100644 index 0000000000..a15901b14b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts @@ -0,0 +1,312 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import type { + Observation, + SavedStack, + ServiceCreation, + Stack, + StackCredentials, +} from "@supabase/stack/effect"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockStdin, + mockRuntimeInfo, + mockTelemetryRuntime, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { mockTelemetryStateTracked } from "../../../../../tests/helpers/command-mocks.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { GLOBAL_FLAGS, WorkdirFlag } from "../../../../command-internal/global-flags.ts"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { StackApi, StackTargetResolver } from "../stack.shared.ts"; +import { stackStatusCommand } from "../status/status.command.ts"; +import { statusEnvPointer } from "./start-summary.format.ts"; + +/** Distinguishable credentials, keyed by a short tag, for one fake target stack. */ +const credentialsFor = (tag: string): StackCredentials => ({ + jwtSecret: `${tag}-secret`.padEnd(32, "0"), + postgresRootKey: `root-key-${tag}`, + databasePassword: `password-${tag}`, + publishableKey: `sb_publishable_${tag}`, + secretKey: `sb_secret_${tag}`, + anonKey: `anon-${tag}`, + serviceRoleKey: `service-${tag}`, + jwks: "{}", + gotrueJwtKeys: "[]", + remoteJwks: "[]", + anonKeyIsOverride: false, + serviceRoleKeyIsOverride: false, +}); + +const databaseCreation = (sqlPort: number, credentials: StackCredentials): ServiceCreation => ({ + service: "database", + config: { + version: "17", + databasePassword: Redacted.make(credentials.databasePassword), + jwtSecret: Redacted.make(credentials.jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: sqlPort } }, +}); + +/** A running, credentialed single-database stack distinguishable by its saved sql port. */ +function makeDatabaseStack(sqlPort: number, credentials: StackCredentials): Stack { + const creation = databaseCreation(sqlPort, credentials); + const observation: Observation = { + id: "database-id", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: sqlPort }], + config: creation, + lifecycle: "running", + health: "healthy", + error: undefined, + cleanupError: undefined, + exit: undefined, + currentOperation: undefined, + launchId: undefined, + intentRevision: 1, + wakeEnabled: false, + registered: true, + }; + const databaseInstance = { + id: "database-id", + service: "database" as const, + start: Effect.die("unused"), + ready: Effect.die("unused"), + stop: Effect.die("unused"), + restart: () => Effect.die("unused"), + destroy: Effect.die("unused"), + prepare: Effect.die("unused"), + status: Effect.succeed(observation), + followStatus: Stream.empty, + logs: Stream.empty, + credentials: () => Effect.succeed({}), + saveSnapshot: () => Effect.die("unused"), + restoreSnapshot: () => Effect.die("unused"), + resetData: Effect.die("unused"), + }; + return { + id: `stack-${sqlPort}`, + services: { + create: () => Effect.die("unused"), + get: () => Effect.succeed(databaseInstance), + list: Effect.succeed([databaseInstance]), + }, + credentials: { get: Effect.succeed(credentials) }, + composition: { + plan: () => Effect.die("unused"), + supabase: () => Effect.die("unused"), + configure: () => Effect.die("unused"), + describe: Effect.succeed({ + members: [{ id: "database-id", activation: "eager" as const }], + dependencies: [], + }), + start: Effect.die("unused"), + stop: Effect.die("unused"), + restart: Effect.die("unused"), + }, + stop: Effect.die("unused"), + destroy: Effect.die("unused"), + commands: { run: () => Effect.die("unused") }, + } satisfies Stack; +} + +const definitionFor = (id: string, projectRoot: string, creation: ServiceCreation): SavedStack => ({ + id, + lifetime: "detached", + identity: { projectRoot, branchContext: "pointer-test", stackName: id }, + runtime: "native", + instances: [{ id: "database-id", creation }], + composition: { members: [{ id: "database-id", activation: "eager" }], dependencies: [] }, + ports: [], +}); + +interface TargetSpec { + readonly projectRoot: string; + readonly name?: string; + readonly sqlPort: number; + readonly tag: string; +} + +/** The fake resolver's key, matching the real resolver's `(projectRoot, name)` lookup. */ +const targetKey = (projectRoot: string, name: string | undefined) => + `${projectRoot}\u0000${name ?? ""}`; + +/** + * Runs `stackStatusCommand` with the given literal argv (excluding the `supabase` program + * name) against a fake resolver stocked with `specs`, so a `--stack` that failed to reach the + * resolver resolves the project's default stack instead of the intended one — a distinguishable + * failure rather than a silent pass. + */ +const runStatusEnv = (input: { + readonly argv: ReadonlyArray; + readonly ambientCwd: string; + readonly specs: ReadonlyArray; +}) => + Effect.gen(function* () { + const targets = new Map(); + const stacksById = new Map(); + for (const spec of input.specs) { + const credentials = credentialsFor(spec.tag); + const stack = makeDatabaseStack(spec.sqlPort, credentials); + targets.set(targetKey(spec.projectRoot, spec.name), { + id: stack.id, + definition: definitionFor( + stack.id, + spec.projectRoot, + databaseCreation(spec.sqlPort, credentials), + ), + }); + stacksById.set(stack.id, stack); + } + + const resolver = Layer.succeed(StackTargetResolver, { + resolve: (resolveInput) => + Effect.sync(() => { + const target = targets.get(targetKey(resolveInput.projectRoot, resolveInput.name)); + return { + projectRoot: resolveInput.projectRoot, + ...(target === undefined ? {} : { id: target.id, definition: target.definition }), + hostRunning: target !== undefined, + }; + }), + }); + const api = Layer.succeed(StackApi, { + create: () => Effect.die("unused"), + open: ({ id }) => { + const stack = stacksById.get(id); + return stack === undefined ? Effect.die(`unknown stack ${id}`) : Effect.succeed(stack); + }, + discover: () => Effect.die("unused"), + find: () => Effect.die("unused"), + }); + // Only `--workdir` selects the project here; an absent flag falls back to the ambient cwd, + // which is registered under its own distinguishable stack so a dropped `--workdir` fails + // the test's assertions instead of passing unnoticed. + const commandSettings = Layer.effect( + CommandSettings, + Effect.gen(function* () { + const workdirFlag = yield* WorkdirFlag; + const workdir = Option.getOrElse(workdirFlag, () => input.ambientCwd); + return CommandSettings.of({ + profile: "supabase", + profileEnvValue: Option.none(), + supabaseHome: "/pointer-test/.supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + accessToken: Option.none(), + dbPassword: Option.none(), + githubToken: Option.none(), + projectId: Option.none(), + workdir, + explicitWorkdir: Option.isSome(workdirFlag), + workdirEnvValue: Option.none(), + userAgent: "SupabaseCLI/pointer-test", + }); + }), + ); + + const output = mockOutput({ format: "json" }); + const analytics = mockAnalytics(); + const telemetry = mockTelemetryStateTracked(); + // `Command.provide` (not `Effect.provide` on the whole run) so `commandSettings`'s + // `WorkdirFlag` read resolves against this command node's own parsed flags, the same + // timing the production `stackRuntimeLayer` composition relies on. + const command = stackStatusCommand.pipe( + Command.provide(commandRuntimeLayer(["status"])), + Command.provide(Layer.mergeAll(resolver, api, commandSettings, telemetry.layer)), + ); + const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([command]), + Command.withGlobalFlags(GLOBAL_FLAGS), + ); + + yield* Command.runWith(testRoot, { version: "0.0.0-test" })(input.argv).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + output.layer, + analytics.layer, + mockProcessControl().layer, + Layer.succeed(CliArgs, { args: input.argv }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockStdin(false), + mockRuntimeInfo({ cwd: input.ambientCwd, homeDir: "/pointer-test/home" }), + mockTelemetryRuntime({ + configDir: "/pointer-test/.supabase", + tracesDir: "/pointer-test/.supabase/traces", + }), + ), + ), + ); + + return yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(output.rawChunks.map(({ text }) => text).join("")); + }).pipe(Effect.provide(BunServices.layer)); + +describe("stack start export pointer", () => { + it.live( + "the printed pointer's argv resolves --workdir and the named --stack to that stack, not the caller's cwd or the project's default stack", + () => + Effect.gen(function* () { + const projectRoot = "/pointer-test/project"; + const ambientCwd = "/pointer-test/project-b"; + const values = yield* runStatusEnv({ + argv: ["status", "--env", "--workdir", projectRoot, "--stack", "docker"], + ambientCwd, + specs: [ + { projectRoot, sqlPort: 40001, tag: "default" }, + { projectRoot, name: "docker", sqlPort: 40002, tag: "named" }, + { projectRoot: ambientCwd, sqlPort: 40003, tag: "cwd" }, + ], + }); + expect(values.DB_URL).toContain(":40002/"); + expect(values.PUBLISHABLE_KEY).toBe("sb_publishable_named"); + + // Pass `"posix"` explicitly so this assertion doesn't depend on the host running the + // test; `currentShellPlatform()` would render PowerShell quoting on win32. + const pointer = statusEnvPointer( + { explicitWorkdir: true, projectRoot, stack: "docker" }, + "posix", + ); + expect(pointer).toBe(`supabase status --env --workdir ${projectRoot} --stack docker`); + }), + ); + + it.live( + "quotes a workdir and stack name that need it, and the same raw values still resolve that stack", + () => + Effect.gen(function* () { + const projectRoot = "/pointer-test/project with space"; + const stackName = "feature one's box"; + const ambientCwd = "/pointer-test/project-b"; + const values = yield* runStatusEnv({ + argv: ["status", "--env", "--workdir", projectRoot, "--stack", stackName], + ambientCwd, + specs: [ + { projectRoot, sqlPort: 40004, tag: "default" }, + { projectRoot, name: stackName, sqlPort: 40005, tag: "named" }, + ], + }); + expect(values.DB_URL).toContain(":40005/"); + expect(values.PUBLISHABLE_KEY).toBe("sb_publishable_named"); + + const pointer = statusEnvPointer( + { explicitWorkdir: true, projectRoot, stack: stackName }, + "posix", + ); + expect(pointer).toBe( + `supabase status --env --workdir '${projectRoot}' --stack 'feature one'"'"'s box'`, + ); + }), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/start/start-summary.format.ts b/apps/cli/src/commands/experimental/stack/start/start-summary.format.ts new file mode 100644 index 0000000000..6717df047c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start-summary.format.ts @@ -0,0 +1,23 @@ +import { + shellQuoteArgument, + type ShellPlatform, +} from "../../../../command-internal/shell-quote.ts"; + +export interface StatusEnvPointerInput { + readonly explicitWorkdir: boolean; + readonly projectRoot: string; + readonly stack?: string; + readonly stackId?: string; +} + +/** The `supabase status --env ...` pointer `start` prints to reproduce this stack's selection. */ +export const statusEnvPointer = (input: StatusEnvPointerInput, platform: ShellPlatform): string => { + const selector = [ + ...(input.explicitWorkdir ? ["--workdir", input.projectRoot] : []), + ...(input.stack === undefined ? [] : ["--stack", input.stack]), + ...(input.stackId === undefined ? [] : ["--stack-id", input.stackId]), + ] + .map((argument) => ` ${shellQuoteArgument(argument, platform)}`) + .join(""); + return `supabase status --env${selector}`; +}; diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index acdc7b2009..fca1046b9b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -450,16 +450,15 @@ describe("stack start (compiled e2e)", () => { env.stdout, ); expect(Object.keys(variables)).toEqual([ + "API_URL", + "REST_URL", "DB_URL", - "ANON_KEY", - "SERVICE_ROLE_KEY", "PUBLISHABLE_KEY", "SECRET_KEY", - "API_URL", + "ANON_KEY", + "SERVICE_ROLE_KEY", ]); - expect(variables.DB_URL).toMatch( - /^postgresql:\/\/supabase_admin:.+@.+:\d+\/postgres(?:\?.*)?$/u, - ); + expect(variables.DB_URL).toMatch(/^postgresql:\/\/postgres:.+@.+:\d+\/postgres$/u); expect(variables.PUBLISHABLE_KEY).toMatch(/^sb_publishable_.+$/u); expect(variables.SECRET_KEY).toMatch(/^sb_secret_.+$/u); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index f79bdc956e..ad8262f771 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,16 +1,16 @@ import { defaultRuntime } from "@supabase/stack/internal/artifacts"; import { + connectionEnv, renderStackSummary, + stackConnections, stackEndpoints, summaryCredentials, type StackServiceView, } from "../stack-summary.ts"; import { gray } from "../../../../command-internal/colors.ts"; -import { - currentShellPlatform, - shellQuoteArgument, -} from "../../../../command-internal/shell-quote.ts"; +import { currentShellPlatform } from "../../../../command-internal/shell-quote.ts"; import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; +import { statusEnvPointer } from "./start-summary.format.ts"; import { automaticRuntimeNotice, selectStackRuntime, @@ -302,16 +302,22 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags stackAcquireError(cause, { selectedRuntime, runtime, creating: target.id === undefined }), ), ); - const platform = currentShellPlatform(); - const selector = [ - ...(settings.explicitWorkdir ? ["--workdir", target.projectRoot] : []), - ...(Option.isSome(flags.stack) ? ["--stack", flags.stack.value] : []), - ...(Option.isSome(flags.stackId) ? ["--stack-id", flags.stackId.value] : []), - ] - .map((argument) => ` ${shellQuoteArgument(argument, platform)}`) - .join(""); + const statusPointer = statusEnvPointer( + { + explicitWorkdir: settings.explicitWorkdir, + projectRoot: target.projectRoot, + ...(Option.isSome(flags.stack) ? { stack: flags.stack.value } : {}), + ...(Option.isSome(flags.stackId) ? { stackId: flags.stackId.value } : {}), + }, + currentShellPlatform(), + ); const reportReady = (report: Effect.Success>, message: string) => Effect.gen(function* () { + const credentials = yield* summaryCredentials(stack.credentials.get, output.warn); + const connections = stackConnections( + report.views.filter(({ activation }) => activation !== undefined), + ); + const env = connectionEnv(connections, credentials); if (output.format !== "text") return yield* output.success(message, { id: stack.id, @@ -320,11 +326,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags lazy_services: report.views .filter(({ activation }) => activation === "lazy") .map(({ service }) => service), + env, }); if (message.length > 0) yield* output.success(message); - const credentials = yield* summaryCredentials(stack.credentials.get, output.warn); yield* output.raw( - `\n${renderStackSummary(report.views, credentials)}\n${gray(`Runtime: ${selectedRuntime}`, process.stdout)}\nRun supabase status --env${selector} to export these values as environment variables.\n`, + `\n${renderStackSummary(report.views, credentials)}\n${gray(`Runtime: ${selectedRuntime}`, process.stdout)}\nRun ${statusPointer} to export these values as environment variables.\n`, ); }); const runtimeNotice = diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index c19a3cd187..c1267d0a4f 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -576,6 +576,13 @@ describe("experimental stack start", () => { }, }, lazy_services: [], + env: { + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-token", + }, }, }), ); @@ -626,14 +633,28 @@ describe("experimental stack start", () => { const fixture = fakeStack(); const json = mockOutput({ format: "json" }); yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); - expect(json.messages.find(({ data }) => data !== undefined)?.data).toMatchObject({ + const data = json.messages.find(({ data }) => data !== undefined)?.data as { + endpoints: Readonly>; + }; + expect(data).toMatchObject({ runtime: "native", endpoints: { "rest.http": { url: "http://127.0.0.1:23457" }, - "studio.mcp": { port: 23458, url: "http://127.0.0.1:23458/api/mcp" }, }, lazy_services: ["pgmeta", "rest", "studio"], + env: { + API_URL: "http://127.0.0.1:23457", + REST_URL: "http://127.0.0.1:23457/rest/v1", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + STUDIO_URL: "http://127.0.0.1:23458", + MCP_URL: "http://127.0.0.1:23457/mcp", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-token", + }, }); + expect(data.endpoints).not.toHaveProperty("studio.mcp"); yield* fixture.stack.composition.stop; const text = mockOutput(); @@ -641,7 +662,7 @@ describe("experimental stack start", () => { Effect.provide(layers(root, fixture, text, true, true)), ); expect(text.stdoutText).toMatch(/Project URL +│ http:\/\/127\.0\.0\.1:23457 +│/u); - expect(text.stdoutText).toMatch(/MCP +│ http:\/\/127\.0\.0\.1:23458\/api\/mcp +│/u); + expect(text.stdoutText).toMatch(/MCP +│ http:\/\/127\.0\.0\.1:23457\/mcp +│/u); expect(text.stdoutText).not.toContain("GraphQL"); expect(text.stdoutText).toMatch(/Secret +│ \S+ +│/u); expect(text.stdoutText).toMatch(/rest +│ running · healthy · lazy +│/u); @@ -653,6 +674,61 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("returns the connection env for an already-running stack in JSON", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-already-running-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "already-running"\n[edge_runtime]\nenabled = false\n', + ); + const excluded = ["auth", "realtime", "storage", "functions", "mail", "analytics", "pooler"]; + const fixture = fakeStack(); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture))); + const json = mockOutput({ format: "json" }); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); + expect(fixture.composed).toBe(1); + const data = json.messages.find(({ data }) => data !== undefined)?.data as { + env: Readonly>; + }; + expect(data.env).toMatchObject({ + API_URL: "http://127.0.0.1:23457", + MCP_URL: "http://127.0.0.1:23457/mcp", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("returns the connection env for a resumed stack in JSON", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-resumed-env-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "resumed-env"\n[edge_runtime]\nenabled = false\n', + ); + const excluded = ["auth", "realtime", "storage", "functions", "mail", "analytics", "pooler"]; + const fixture = fakeStack(); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture))); + const rest = fixture.members.find(({ service }) => service === "rest"); + if (rest === undefined) return yield* Effect.die("Expected a REST member"); + // The database stays running; another member starting keeps the composition short of + // fully started, so the next start takes the resumed branch, not the already-running one. + fixture.setMemberStatus(rest.id, { lifecycle: "starting", health: "starting" }); + const json = mockOutput({ format: "json" }); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); + expect(fixture.compositionStarts).toBe(2); + expect(fixture.composed).toBe(1); + const data = json.messages.find(({ data }) => data !== undefined)?.data as { + env: Readonly>; + }; + expect(data.env.DB_URL).toBe("postgresql://postgres:postgres@127.0.0.1:23456/postgres"); + expect(data.env.API_URL).toBe("http://127.0.0.1:23457"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("names the services that failed when the composition start fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index ac8129aaac..3868370a73 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -42,23 +42,35 @@ state, health, and activation; sleeping lazy services are marked as starting on first request. Service errors follow the tables, and config drift ends the output as one muted line unless the configuration drifted. Stack and service IDs appear only in JSON. JSON nests only identity fields under `identity`; runtime, lifecycle, -readiness, composition, services, endpoints, and config drift remain top-level -fields. `endpoints` also carries `studio.mcp`, Studio's MCP URL, when Studio is -present. Stack identity, endpoints, and credentials are never telemetry -properties. +readiness, composition, services, endpoints, config drift, and `env` remain +top-level fields. `endpoints` reports the raw observations +(`service.endpoint`) of every observed instance, not only composition members; +it carries no synthetic entries. `env` is the member-scoped connection map +`--env` exports (see below), degrading to whatever is available when +credentials or the owner are unreachable; it never fails the command. Plain +`status` JSON `env` comes from saved bindings and can list values for stopped +or sleeping members, while `--env` requires a reachable owner and a running +primary database. Stack identity, endpoints, and credentials are never +telemetry properties. ## Exporting environment variables (`--env`) `--env` is the explicit environment-export operation. It requires a reachable -owner and a running primary database, then derives the database URL from the -observed SQL endpoint and saved database credentials, using the `supabase_admin` role. -`API_URL` is the shared API listener of any member routed through it, and -`MCP_URL` is Studio's MCP endpoint. It does not request live -credentials or launch an owner, and it does not load or compare project -configuration. Text output emits dotenv assignments; +owner and a running primary database, then derives `DB_URL` from the observed +SQL endpoint and the saved database password, using the `postgres` role and no +query string. `API_URL` is the shared API listener of any member routed +through it, and `MCP_URL` is `/mcp`, present only when the shared API +listener is present and Studio is a composition member with an HTTP endpoint. +It does not request live credentials or launch an owner, and it does not load +or compare project configuration. Text output emits dotenv assignments; JSON and stream-JSON output emit a plain variable map under a successful result. -Unavailable optional credentials and endpoints are omitted. The derived -`ANON_KEY` and `SERVICE_ROLE_KEY` values are emitted from the required saved database JWT secret. +Unavailable optional credentials and endpoints are omitted. The exported +variable set is `API_URL`, `REST_URL`, `FUNCTIONS_URL`, `DB_URL`, `STUDIO_URL`, +`MCP_URL`, `MAILPIT_URL`, `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, and +`SERVICE_ROLE_KEY`; `REST_URL` and `FUNCTIONS_URL` are `/rest/v1` and +`/functions/v1`, present only when their member is a composition +member with an HTTP endpoint. `ANON_KEY` and `SERVICE_ROLE_KEY` are emitted +from the required saved database JWT secret. `--override-name` renames an exported variable, accepting repeated flags or a comma-separated list of `EXPORTED_VARIABLE=VALID_ENV_NAME` entries. It requires diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 0a5b630eca..3c0d8c4e12 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -1,23 +1,30 @@ import { Effect } from "effect"; import type { StackCredentials } from "@supabase/stack/effect"; -import type { StackConnections } from "../stack-summary.ts"; +import { connectionEnv, type StackConnections } from "../stack-summary.ts"; import { StackCommandStatusError } from "./status.errors.ts"; const variableNames = [ "API_URL", + "REST_URL", + "FUNCTIONS_URL", "DB_URL", - "ANON_KEY", - "SERVICE_ROLE_KEY", - "PUBLISHABLE_KEY", - "SECRET_KEY", "STUDIO_URL", - "INBUCKET_URL", "MCP_URL", + "MAILPIT_URL", + "INBUCKET_URL", + "PUBLISHABLE_KEY", + "SECRET_KEY", + "ANON_KEY", + "SERVICE_ROLE_KEY", +] as const; + +/** Names the stack backend used to export, since removed; still worth naming in errors. */ +const removedVariableNames = new Set([ "S3_PROTOCOL_ACCESS_KEY_ID", "S3_PROTOCOL_ACCESS_KEY_SECRET", "S3_PROTOCOL_REGION", "S3_PROTOCOL_URL", -] as const; +]); export const stackEnvOverrides = (entries: ReadonlyArray) => Effect.gen(function* () { @@ -27,15 +34,20 @@ export const stackEnvOverrides = (entries: ReadonlyArray) => const [source, target, extra] = entry.split("="); if ( source === undefined || - !names.has(source) || target === undefined || extra !== undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target) ) return yield* new StackCommandStatusError({ reason: "flags", - message: - "--override-name must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.", + message: `--override-name entry "${entry}" must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.`, + }); + if (!names.has(source)) + return yield* new StackCommandStatusError({ + reason: "flags", + message: removedVariableNames.has(source) + ? `--override-name entry "${entry}" refers to ${source}, which is not exported by the stack backend.` + : `--override-name entry "${entry}" refers to ${source}, which is not an exported variable; valid variables are ${variableNames.join(", ")}.`, }); if (sources.has(source)) return yield* new StackCommandStatusError({ @@ -53,33 +65,20 @@ export const stackEnvOverrides = (entries: ReadonlyArray) => return names; }); +/** The `status --env` variable map, with `--override-name` remapping applied. */ export const stackEnvValues = ( - status: { - readonly urls: Pick; - readonly credentials?: Pick< - StackCredentials, - "publishableKey" | "secretKey" | "anonKey" | "serviceRoleKey" - >; - }, - credentials: Readonly>, + connections: StackConnections, + credentials: + | Pick + | undefined, names: ReadonlyMap, -): Readonly> => { - const values: Record = {}; - if (credentials.databaseUrl !== undefined) values.DB_URL = credentials.databaseUrl; - if (status.credentials !== undefined) { - values.ANON_KEY = status.credentials.anonKey; - values.SERVICE_ROLE_KEY = status.credentials.serviceRoleKey; - values.PUBLISHABLE_KEY = status.credentials.publishableKey; - values.SECRET_KEY = status.credentials.secretKey; - } - if (status.urls.api !== undefined) values.API_URL = status.urls.api; - if (status.urls.studio !== undefined) values.STUDIO_URL = status.urls.studio; - if (status.urls.mcp !== undefined) values.MCP_URL = status.urls.mcp; - if (status.urls.mailpit !== undefined) values.INBUCKET_URL = status.urls.mailpit; - return Object.fromEntries( - Object.entries(values).map(([key, value]) => [names.get(key) ?? key, value]), +): Readonly> => + Object.fromEntries( + Object.entries(connectionEnv(connections, credentials)).map(([key, value]) => [ + names.get(key) ?? key, + value, + ]), ); -}; const dotenvQuote = (value: string): string | undefined => { if (!value.includes("'")) return "'"; diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index 587a4cc2cd..383c8499e8 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -46,16 +46,13 @@ describe("stack environment overrides", () => { Effect.gen(function* () { const names = yield* stackEnvOverrides([]); const values = stackEnvValues( + {}, { - urls: {}, - credentials: { - publishableKey: "sb_publishable_saved", - secretKey: "sb_secret_saved", - anonKey: "asymmetric-anon-token", - serviceRoleKey: "asymmetric-service-token", - }, + publishableKey: "sb_publishable_saved", + secretKey: "sb_secret_saved", + anonKey: "asymmetric-anon-token", + serviceRoleKey: "asymmetric-service-token", }, - {}, names, ); expect(values).toEqual({ @@ -67,11 +64,21 @@ describe("stack environment overrides", () => { }), ); + it.effect("emits INBUCKET_URL as a deprecated alias of MAILPIT_URL", () => + Effect.gen(function* () { + const names = yield* stackEnvOverrides([]); + expect(stackEnvValues({ mailpit: "http://127.0.0.1:54324" }, undefined, names)).toEqual({ + MAILPIT_URL: "http://127.0.0.1:54324", + INBUCKET_URL: "http://127.0.0.1:54324", + }); + }), + ); + it.effect("accepts renames and rejects malformed or colliding destinations", () => Effect.gen(function* () { const names = yield* stackEnvOverrides(["API_URL=NEXT_PUBLIC_API_URL"]); expect(names.get("API_URL")).toBe("NEXT_PUBLIC_API_URL"); - expect(stackEnvValues({ urls: { api: "http://127.0.0.1:54321" } }, {}, names)).toEqual({ + expect(stackEnvValues({ api: "http://127.0.0.1:54321" }, undefined, names)).toEqual({ NEXT_PUBLIC_API_URL: "http://127.0.0.1:54321", }); @@ -87,4 +94,53 @@ describe("stack environment overrides", () => { } }), ); + + it.effect("accepts every current variable name and rejects removed S3_PROTOCOL names", () => + Effect.gen(function* () { + for (const name of [ + "API_URL", + "REST_URL", + "FUNCTIONS_URL", + "DB_URL", + "STUDIO_URL", + "MCP_URL", + "MAILPIT_URL", + "INBUCKET_URL", + "PUBLISHABLE_KEY", + "SECRET_KEY", + "ANON_KEY", + "SERVICE_ROLE_KEY", + ]) { + const names = yield* stackEnvOverrides([`${name}=RENAMED_${name}`]); + expect(names.get(name)).toBe(`RENAMED_${name}`); + } + for (const removed of [ + "S3_PROTOCOL_ACCESS_KEY_ID", + "S3_PROTOCOL_ACCESS_KEY_SECRET", + "S3_PROTOCOL_REGION", + "S3_PROTOCOL_URL", + "JWT_SECRET", + ]) { + const error = yield* stackEnvOverrides([`${removed}=RENAMED`]).pipe(Effect.flip); + expect(error.reason).toBe("flags"); + } + }), + ); + + it.effect("names the offending entry in --override-name error messages", () => + Effect.gen(function* () { + const malformed = yield* stackEnvOverrides(["API_URL="]).pipe(Effect.flip); + expect(malformed.message).toContain('"API_URL="'); + + const removed = yield* stackEnvOverrides(["S3_PROTOCOL_URL=RENAMED"]).pipe(Effect.flip); + expect(removed.message).toContain("S3_PROTOCOL_URL"); + expect(removed.message).toContain("not exported by the stack backend"); + + const unknown = yield* stackEnvOverrides(["UNKNOWN=RENAMED"]).pipe(Effect.flip); + expect(unknown.message).toContain("UNKNOWN"); + expect(unknown.message).toContain("API_URL"); + expect(unknown.message).toContain("REST_URL"); + expect(unknown.message).toContain("FUNCTIONS_URL"); + }), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index f3490e8f67..e82f9ab753 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -10,6 +10,7 @@ import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; import { bold, gray, green, red, yellow } from "../../../../command-internal/colors.ts"; import { + connectionEnv, renderStackSummary, serviceState, stackConnections, @@ -77,6 +78,8 @@ type StackReport = { readonly message: string; readonly paths?: ReadonlyArray; }; + /** The `status --env` connection map, degrading to what's available when credentials or the owner are unreachable. */ + readonly env: Readonly>; }; const mapTargetError = (error: StackTargetError) => @@ -159,6 +162,7 @@ const reportFor = ( observed: ReadonlyArray, members: ReadonlyArray<{ readonly id: string; readonly activation: string }>, configDrift: StackReport["config_drift"], + env: StackReport["env"], ): StackReport => { const services = observed.map(serviceReport); const endpoints = stackEndpoints( @@ -191,6 +195,7 @@ const reportFor = ( services, endpoints, config_drift: configDrift, + env, }; }; @@ -384,6 +389,9 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( .pipe(Effect.mapError(mapStackError)); const observed = yield* observe(stack, target.owner); const memberIds = new Set(observed.members.map(({ id }) => id)); + const members = observed.observed.flatMap(({ instance, observation }) => + memberIds.has(instance.id) ? [{ service: instance.service, observation }] : [], + ); if (flags.env) { const database = observed.observed.find( ({ instance }) => memberIds.has(instance.id) && instance.service === "database", @@ -413,16 +421,8 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( message: "The stack's active credentials are unavailable for environment export.", suggestion: "Run supabase stack start first.", }); - const connections = stackConnections( - observed.observed.flatMap(({ instance, observation }) => - memberIds.has(instance.id) ? [{ service: instance.service, observation }] : [], - ), - ); - const values = stackEnvValues( - { urls: connections, credentials: identity }, - connections.database === undefined ? {} : { databaseUrl: connections.database }, - envNames, - ); + const connections = stackConnections(members); + const values = stackEnvValues(connections, identity, envNames); if (output.format === "text") yield* output.raw(yield* encodeStackEnv(values)); else yield* output.result(values); return; @@ -434,22 +434,18 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( ({ instance }) => memberIds.has(instance.id) && instance.service === "functions", ), ); + const credentials = yield* summaryCredentials(stack.credentials.get, output.warn); + const connections = stackConnections(members); const report = reportFor( target.definition, target.owner, observed.observed, observed.members, config, + connectionEnv(connections, credentials), ); if (output.format === "text") - yield* output.raw( - render( - report, - observed.observed, - observed.members, - yield* summaryCredentials(stack.credentials.get, output.warn), - ), - ); + yield* output.raw(render(report, observed.observed, observed.members, credentials)); else yield* output.success("", report); }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index ff7f06f02a..cdced86d6f 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Schema, Stream } from "effect"; import { type Observation, type PlannedInstance, @@ -140,6 +140,7 @@ const makeStack = ( members: ReadonlyArray<{ readonly id: string; readonly activation: "eager" | "lazy" }>, credentials: StackCredentials = savedCredentials, planned: ReadonlyArray = [], + credentialsUnavailable = false, ): OpenedStack => ({ id: stackId, services: { @@ -147,7 +148,11 @@ const makeStack = ( get: (_id) => Effect.die("unused"), list: Effect.succeed([...services]), }, - credentials: { get: Effect.succeed(credentials) }, + credentials: { + get: credentialsUnavailable + ? Effect.fail(new StackError({ operation: "credentials", message: "owner unreachable" })) + : Effect.succeed(credentials), + }, composition: { plan: () => Effect.succeed(planned), supabase: (_services, _options) => Effect.die("unused"), @@ -171,6 +176,7 @@ const runStatus = (input: { readonly outputFormat?: StatusOutputFormat; readonly config?: "missing" | "invalid" | "explicit" | "multiline-functions-env"; readonly stackCredentials?: StackCredentials; + readonly credentialsUnavailable?: boolean; readonly planned?: ReadonlyArray; readonly flags?: StackStatusFlags; }) => @@ -205,6 +211,7 @@ const runStatus = (input: { input.members ?? input.services.map(({ id }) => ({ id, activation: "lazy" as const })), input.stackCredentials, input.planned, + input.credentialsUnavailable ?? false, ); const definition = { id: stackId, @@ -301,7 +308,7 @@ it.live("renders connections and a services summary without internal IDs", () => const text = run.out.stdoutText; expect(text).toMatch(/^Stack status-stack · unhealthy · native · /u); expect(text).toMatch(/Project URL │ http:\/\/127\.0\.0\.1:54321 +│/u); - expect(text).toContain("postgresql://supabase_admin:postgres@127.0.0.1:54322/postgres"); + expect(text).toContain("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); expect(text).toMatch(/Publishable │ saved-publishable-key +│/u); expect(text).toMatch(/database +│ running · healthy · eager +│/u); expect(text).toMatch(/rest +│ sleeping · starts on first request +│/u); @@ -460,7 +467,59 @@ it.live("reports the planned differences of composition members as drift", () => }), ); -it.live("reports the Studio MCP and gateway API endpoints without REST", () => +it.live( + "reports the gateway-served MCP endpoint at the API URL, with no synthetic endpoint entry", + () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + wakeEnabled: false, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "auth-id", + creation: auth, + statusCalls: { value: 0 }, + observation: makeObservation("auth-id", auth, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + makeService({ + id: "studio-id", + creation: studio, + statusCalls: { value: 0 }, + observation: makeObservation("studio-id", studio, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54323 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + endpoints: Readonly>; + env: Readonly>; + }; + expect(result).toMatchObject({ + endpoints: { "studio.http": { url: "http://127.0.0.1:54323" } }, + env: { MCP_URL: "http://127.0.0.1:54321/mcp" }, + }); + expect(result.endpoints).not.toHaveProperty("studio.mcp"); + const env = yield* runStatus({ services, reachable: true, flags: flags({ env: true }) }); + yield* env.effect; + expect(env.out.stdoutText).toContain("MCP_URL='http://127.0.0.1:54321/mcp'"); + expect(env.out.stdoutText).toContain("STUDIO_URL='http://127.0.0.1:54323'"); + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + }), +); + +it.live("omits MCP_URL when no member exposes the shared API listener", () => Effect.gen(function* () { const services = [ makeService({ @@ -474,14 +533,6 @@ it.live("reports the Studio MCP and gateway API endpoints without REST", () => endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], }), }), - makeService({ - id: "auth-id", - creation: auth, - statusCalls: { value: 0 }, - observation: makeObservation("auth-id", auth, { - endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], - }), - }), makeService({ id: "studio-id", creation: studio, @@ -493,17 +544,42 @@ it.live("reports the Studio MCP and gateway API endpoints without REST", () => ]; const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); yield* report.effect; - expect(report.out.messages.find((message) => message.type === "success")?.data).toMatchObject({ - endpoints: { - "studio.http": { url: "http://127.0.0.1:54323" }, - "studio.mcp": { port: 54323, url: "http://127.0.0.1:54323/api/mcp" }, - }, - }); - const env = yield* runStatus({ services, reachable: true, flags: flags({ env: true }) }); - yield* env.effect; - expect(env.out.stdoutText).toContain("MCP_URL='http://127.0.0.1:54323/api/mcp'"); - expect(env.out.stdoutText).toContain("STUDIO_URL='http://127.0.0.1:54323'"); - expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + const result = report.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly>; + }; + expect(result.env).not.toHaveProperty("MCP_URL"); + }), +); + +it.live("omits MCP_URL when Studio is not a composition member", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + wakeEnabled: false, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "auth-id", + creation: auth, + statusCalls: { value: 0 }, + observation: makeObservation("auth-id", auth, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly>; + }; + expect(result.env).not.toHaveProperty("MCP_URL"); }), ); @@ -607,7 +683,7 @@ it.live("exports saved credentials only for a running database", () => }); yield* run.effect; expect(run.out.stdoutText).toContain( - "DB_URL='postgresql://supabase_admin:postgres@127.0.0.1:54322/postgres?connect_timeout=10'", + "DB_URL='postgresql://postgres:postgres@127.0.0.1:54322/postgres'", ); expect(run.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); expect(run.out.stdoutText).toContain("ANON_KEY='saved-anon-token'"); @@ -617,6 +693,112 @@ it.live("exports saved credentials only for a running database", () => }), ); +it.live("derives DB_URL using the postgres role and a non-default saved password", () => + Effect.gen(function* () { + const password = "p@ss w/ord!"; + const customDatabase: ServiceCreation = { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make(password), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: 54322 } }, + }; + const services = [ + makeService({ + id: "database-id", + creation: customDatabase, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", customDatabase, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + ]; + const run = yield* runStatus({ services, reachable: true, flags: flags({ env: true }) }); + yield* run.effect; + expect(run.out.stdoutText).toContain( + `DB_URL='postgresql://postgres:${encodeURIComponent(password)}@127.0.0.1:54322/postgres'`, + ); + }), +); + +it.live("the status JSON env map equals the status --env export for the same stack", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "rest-id", + creation: rest, + statusCalls: { value: 0 }, + observation: makeObservation("rest-id", rest, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly>; + }; + const exported = yield* runStatus({ + services, + reachable: true, + outputFormat: "json", + flags: flags({ env: true }), + }); + yield* exported.effect; + const exportedValues = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(exported.out.stdoutText); + expect(result.env).toEqual(exportedValues); + expect(result.env.REST_URL).toBe("http://127.0.0.1:54321/rest/v1"); + }), +); + +it.live("status JSON env degrades to what's available when credentials are unreachable", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + ]; + const run = yield* runStatus({ + services, + reachable: true, + outputFormat: "json", + credentialsUnavailable: true, + }); + yield* run.effect; + const result = run.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly>; + }; + expect(result.env).not.toHaveProperty("PUBLISHABLE_KEY"); + expect(result.env.DB_URL).toContain("127.0.0.1:54322"); + }), +); + it.live("uses only the composition database for environment export", () => Effect.gen(function* () { const shadow = makeObservation("shadow-db", database, { From b29e7437b278ebe8102fd1334c7adb5a1d79f9ef Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:43:30 +0000 Subject: [PATCH 62/71] fix(cli): decline unrecognised yes/no prompt answers (CLI-2524) (#6910) ## TL;DR stops a piped `db push` from applying migrations when the confirmation answer is not a yes or a no. ## what's biting the user? with stdin piped, `promptYesNo` fell back to the default on any line it could not parse. `db push` defaults to yes, so an answer like `u` or `nope` applied the migrations ## now fixed by: declining any non empty answer that is not `y`, `yes`, `n` or `no` once the prompt was asked, which departs from the Go `PromptYesNo` on purpose. stdin `readLine` no longer trims, so a line of only spaces declines too. an empty line or EOF still takes the default `migrationConfirm` follows the same rule, so `migration fetch` and `migration squash` no longer proceed on a typo the real TTY selector, `--yes`, machine output and `interactive: false` reads are unchanged ## ref: - closes: https://github.com/supabase/cli/issues/6869 --- apps/cli/docs/supabase/config/pull.md | 2 +- apps/cli/src/command-internal/db-pull-run.ts | 4 +- .../cli/src/command-internal/prompt-yes-no.ts | 20 +++--- .../prompt-yes-no.unit.test.ts | 72 ++++++++++++++++++- .../commands/bootstrap/bootstrap.handler.ts | 2 +- .../branches/create/create.handler.ts | 4 +- .../src/commands/config/pull/SIDE_EFFECTS.md | 4 +- .../src/commands/config/pull/pull.command.ts | 2 +- .../commands/db/push/push.integration.test.ts | 39 ++++++++-- .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 2 +- .../commands/gen/signing-key/SIDE_EFFECTS.md | 2 +- .../commands/migration/fetch/SIDE_EFFECTS.md | 2 +- .../migration/fetch/fetch.integration.test.ts | 39 ++++++++++ .../commands/migration/migration.prompt.ts | 8 ++- .../commands/migration/squash/SIDE_EFFECTS.md | 3 +- .../squash/squash.integration.test.ts | 10 +++ apps/cli/src/commands/pull/pull.command.ts | 2 +- .../storage/rm/rm.integration.test.ts | 2 +- apps/cli/src/shared/runtime/stdin.layer.ts | 2 +- .../shared/runtime/stdin.layer.unit.test.ts | 10 +++ apps/cli/src/shared/runtime/stdin.service.ts | 3 +- apps/cli/tests/helpers/mocks.ts | 7 +- 22 files changed, 200 insertions(+), 41 deletions(-) diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index eb35848d67..3e238c1d59 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -12,7 +12,7 @@ Several kinds of values are never written, and are reported instead of silently `config pull` never writes a `config.toml`/`.json` it cannot load back on the next command. Pulling a value that GATES other declared fields — e.g. flipping a disabled provider's `enabled` on — also pulls its now-required sibling values into the same write (not just the gate), so the file never comes out half-configured. When one of those siblings genuinely can't be supplied — the remote doesn't report it, or supplying it would still leave a REQUIRED value missing — the whole toggle is skipped instead, reported with a new reason (`requires values pull cannot write` in text output, `would_invalidate` in the machine payload) plus a note naming the missing field(s) and, when a missing field's local spelling is itself an unresolved `env(VAR)` reference, the exact environment variable to set before rerunning. -`--dry-run` computes and prints exactly what `config diff` would show, then stops — no git check, no prompt, no write. `--output-format json|stream-json` never prompts at all — it returns the confirmation's default value without reading anything, so use `--dry-run` for a preview in those shapes rather than relying on the prompt. A non-interactive run in the default text format (no TTY on stdin) still prints the confirmation to stderr and reads a single line from piped stdin: an explicit `y`/`n` answer is honored, and an empty or unparseable line falls back to the default. `--yes` (or `SUPABASE_YES`) answers the confirmation prompt without asking on an interactive TTY; it does not bypass the uncommitted-changes guard — on the contrary, `--yes` together with uncommitted (or untracked) changes aborts instead of silently overwriting them, since no human is left to read the warning. `--force` writes even when `supabase/config.toml` has uncommitted or untracked changes in git — without it, an interactive run's prompt defaults to "no" instead of "yes", and every other case (non-interactive text, machine-format, or `--yes` on any TTY) aborts outright. +`--dry-run` computes and prints exactly what `config diff` would show, then stops — no git check, no prompt, no write. `--output-format json|stream-json` never prompts at all — it returns the confirmation's default value without reading anything, so use `--dry-run` for a preview in those shapes rather than relying on the prompt. A non-interactive run in the default text format (no TTY on stdin) still prints the confirmation to stderr and reads a single line from piped stdin: an explicit `y`/`yes`/`n`/`no` answer is honored, an empty line falls back to the default, and any other answer declines. `--yes` (or `SUPABASE_YES`) answers the confirmation prompt without asking on an interactive TTY; it does not bypass the uncommitted-changes guard — on the contrary, `--yes` together with uncommitted (or untracked) changes aborts instead of silently overwriting them, since no human is left to read the warning. `--force` writes even when `supabase/config.toml` has uncommitted or untracked changes in git — without it, an interactive run's prompt defaults to "no" instead of "yes", and every other case (non-interactive text, machine-format, or `--yes` on any TTY) aborts outright. A second `config pull` against an already-tracked, unchanged remote always writes nothing — that convergence is the design's own acceptance property, not a special case. It never even checks for uncommitted local changes to the config file in that case, since there is nothing to write either way. A target that isn't tracked yet (a branch or `--remote-label` name with no existing `[remotes.*]` block) still creates that block on its first pull, even when every value it carries already matches the local defaults — it's the second pull against that now-tracked block that then writes nothing. diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 536dfdbd6d..455a000c2e 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -805,8 +805,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( } // Prompt to update the remote migration history table. Returns the default - // (`true`) on `--yes`, on a non-interactive stdin, or on any prompt error — it - // never fails the command. + // (`true`) on `--yes`, on an empty non-interactive stdin, or on any prompt error — + // it never fails the command. let remoteHistoryUpdated = false; const updateHistoryTitle = "Update remote migration history table?"; // `invoke?.assumeYes` overrides this resolution entirely for an in-process diff --git a/apps/cli/src/command-internal/prompt-yes-no.ts b/apps/cli/src/command-internal/prompt-yes-no.ts index 8e4b39740c..d50fc1e741 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.ts @@ -8,7 +8,7 @@ const NON_TTY_TIMEOUT_MILLIS = 100; /** * Parses a yes/no answer, case-insensitively and trimmed: `y`/`yes` → `true`, `n`/`no` → - * `false`, anything else → `undefined` (caller falls back to the default). + * `false`, anything else → `undefined`. */ export const parseYesNo = (input: string): boolean | undefined => { const s = input.trim().toLowerCase(); @@ -26,8 +26,9 @@ export const parseYesNo = (input: string): boolean | undefined => { * `yes` echoes an affirmative answer and returns `true` immediately; non-text output * uses the default silently unless the caller opts into machine-mode piped answers; * a real interactive text TTY prompts via clack; otherwise (including text callers with - * `interactive: false`) it reads one line via the shared `Stdin` reader, falling back to - * the default only when the line is empty or unparseable. + * `interactive: false`) it reads one line via the shared `Stdin` reader: a parsed answer + * wins and an empty line takes the default. Any other line declines, except under + * `interactive: false`, where it takes the default. */ export const promptYesNo = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -52,19 +53,14 @@ export const promptYesNo = Effect.fnUntraced(function* ( // Text `interactive: false` still prints the label and reads one line instead of // silently returning the default — it uses the same non-TTY read path below. if (!interactive || !tty.stdinIsTty) { - // A parsed piped answer wins; an empty or unparseable line falls back to the default. yield* output.raw(`${label} [${choices}] `, "stderr"); const stdin = yield* Stdin; const line = yield* stdin.readLine(NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); - yield* output.raw(`${input}\n`, "stderr"); - if (input.length > 0) { - const answer = parseYesNo(input); - if (answer !== undefined) { - return answer; - } - } - return defaultValue; + yield* output.raw(`${input.trim()}\n`, "stderr"); + // An unrecognised answer is never consent; under `interactive: false` the line may be + // the caller's own script text, so it keeps the default. + return parseYesNo(input) ?? (interactive && input.length > 0 ? false : defaultValue); } return yield* output .promptConfirm(label, { defaultValue }) diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 9702f9f938..230e9b380d 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Effect, Layer, Option, Stream } from "effect"; -import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../tests/helpers/mocks.ts"; import { Output } from "../shared/output/output.service.ts"; import { Stdin } from "../shared/runtime/stdin.service.ts"; @@ -107,3 +107,73 @@ describe("promptYesNo machine consent", () => { }); } }); + +describe("promptYesNo piped text answers", () => { + const ask = (piped: string, defaultValue: boolean, interactive = true) => + Effect.runPromise( + Effect.gen(function* () { + const output = yield* Output; + return yield* promptYesNo(output, false, "Confirm?", defaultValue, interactive); + }).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput().layer, + mockStdin(false, piped), + mockTty({ stdinIsTty: false }), + ), + ), + ), + ); + + it.each(["u", "yess", "nope", " "])( + "declines the unrecognised answer %j whatever the default", + async (answer) => { + expect(await ask(`${answer}\n`, true)).toBe(false); + expect(await ask(`${answer}\n`, false)).toBe(false); + }, + ); + + it("keeps the default for an unrecognised line under interactive: false", async () => { + expect(await ask("echo next-step\n", true, false)).toBe(true); + expect(await ask("echo next-step\n", false, false)).toBe(false); + expect(await ask(" \n", true, false)).toBe(true); + }); + + it.each([true, false])("takes the default %j for an empty line or closed stdin", async (def) => { + for (const interactive of [true, false]) { + expect(await ask("\n", def, interactive)).toBe(def); + expect(await ask("", def, interactive)).toBe(def); + } + }); + + it("reads a blank line as present, then the next answer", async () => { + const lines = await Effect.runPromise( + Effect.gen(function* () { + const output = yield* Output; + const { readLine } = yield* Stdin; + const first = yield* promptYesNo(output, false, "Confirm?", true); + const second = yield* promptYesNo(output, false, "Confirm?", true); + return [first, second, yield* readLine(0), yield* readLine(0)]; + }).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput().layer, + mockStdin(false, "\nn\n\n"), + mockTty({ stdinIsTty: false }), + ), + ), + ), + ); + expect(lines).toEqual([true, false, Option.some(""), Option.none()]); + }); + + it.each([ + ["y", true], + ["yes", true], + ["n", false], + ["no", false], + ] as const)("honours %j over the opposite default", async (answer, expected) => { + expect(await ask(`${answer}\n`, !expected)).toBe(expected); + expect(await ask(`${answer}\n`, !expected, false)).toBe(expected); + }); +}); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 9d0387307e..44d8752802 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -149,7 +149,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ); if (entries.length > 0) { // `--yes`/`SUPABASE_YES` auto-confirms with a ` [Y/n] y` stderr echo; non-TTY - // stdin scans one piped line (100ms) before falling back to Yes. + // stdin scans one piped line (100ms): empty or EOF takes Yes, an unrecognised answer declines. const overwrite = yield* promptYesNo( output, yesFlag, diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index 4a83774540..c736a4a9f5 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -52,8 +52,8 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra const gitBranch = yield* detectGitBranch(); if (Option.isSome(gitBranch) && gitBranch.value.length > 0) { // `--yes`/`SUPABASE_YES` auto-confirms with a `<title> [Y/n] y` stderr echo; non-TTY - // stdin scans one piped line (100ms) before falling back to Yes — `echo n | supabase - // branches create` cancels. + // stdin scans one piped line (100ms): empty or EOF takes Yes, anything but y/yes + // declines — `echo n | supabase branches create` cancels. const yes = yield* resolveYes; const confirmed = yield* promptYesNo( output, diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index fccd2e8bd8..8aa9bb6b52 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -6,8 +6,8 @@ resolution, fetch, and classification, `../diff/`). Prompts for confirmation bef interactive TTY, unless `--yes` is set; `--output-format json|stream-json` never prompts at all and returns the confirmation's default value without reading anything. A non-interactive TEXT run (no TTY on stdin) still prints the confirmation to stderr and reads a single line from piped stdin, -honoring an explicit `y`/`n` answer and falling back to the default otherwise. Never writes on -`--dry-run`, on a declined prompt, or on any error. +honoring an explicit `y`/`yes`/`n`/`no` answer, falling back to the default on an empty line, and +declining any other answer. Never writes on `--dry-run`, on a declined prompt, or on any error. ## Files Read diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index bbdd2882bb..991d3cf068 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -58,7 +58,7 @@ const configPullHandler = (flags: ConfigPullFlags) => export const configPullCommand = Command.make("pull", config).pipe( Command.withDescription( - "Writes configuration from a remote project or branch into supabase/config.toml. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/n honored, default otherwise) — use --dry-run to preview first.", + "Writes configuration from a remote project or branch into supabase/config.toml. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/yes/n/no honored, empty takes the default, anything else declines) — use --dry-run to preview first.", ), Command.withShortDescription("Pull remote config into supabase/config.toml"), Command.withExamples([ diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 1e28ccd443..67ca69a486 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -155,6 +155,7 @@ function setup( files?: Readonly<Record<string, string>>; format?: OutputFormat; confirm?: ReadonlyArray<boolean>; + piped?: string; args?: ReadonlyArray<string>; yes?: boolean; isLocal?: boolean; @@ -233,10 +234,9 @@ function setup( }), BunServices.layer, // Prompts are answered through mockOutput's `promptConfirmResponses` (the - // TTY/clack path); Stdin is only used by promptYesNo's non-TTY branch (unreached - // here). - mockTty({ stdinIsTty: true }), - mockStdin(true), + // TTY/clack path) unless `piped` feeds promptYesNo's non-TTY branch. + mockTty({ stdinIsTty: opts.piped === undefined }), + mockStdin(opts.piped === undefined, opts.piped), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(DnsResolverFlag, "native"), @@ -376,6 +376,37 @@ describe("db push", () => { }); }); + it.live.each(["u", "yess", "nope", " ", "n", "no"])( + "applies nothing when the piped answer is %j", + (answer) => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + piped: `${answer}\n`, + }); + return Effect.gen(function* () { + const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(conn.execs).not.toContain("BEGIN"); + }); + }, + ); + + it.live.each(["y\n", "yes\n", "\n", ""])( + "applies migrations when the piped answer is %j", + (piped) => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + piped, + }); + return Effect.gen(function* () { + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs).toContain("BEGIN"); + }); + }, + ); + it.live("prints the plan without applying in dry-run mode", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 4fdb4e5b37..0db11b30dc 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -45,7 +45,7 @@ const consolePromptText = Effect.fnUntraced(function* (label: string) { const line = yield* stdin.readLine( tty.stdinIsTty ? GO_CONSOLE_TTY_TIMEOUT_MILLIS : GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS, ); - const input = Option.getOrElse(line, () => ""); + const input = Option.getOrElse(line, () => "").trim(); if (!tty.stdinIsTty) { yield* output.raw(`${input}\n`, "stderr"); } diff --git a/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md index 2fc24569dd..d485373215 100644 --- a/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md @@ -58,7 +58,7 @@ Same as `--output-format json` above. - `--algorithm` accepts `ES256` (default, recommended) or `RS256`. - `--append` appends the new key to an existing keys file instead of overwriting. -- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.<env>[.local]` files, shell wins) and an explicit `--yes=false` override (flag wins over env; an omitted flag falls back to the env var, resolved after the project env loads — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`) — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. +- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.<env>[.local]` files, shell wins) and an explicit `--yes=false` override (flag wins over env; an omitted flag falls back to the env var, resolved after the project env loads — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`) — a non-empty piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) declines. - `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`. - Generated keys are JWKs, not PEM files. - No network or Management API calls are involved. diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index 3f8a18dc3e..7b9d25165c 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -60,7 +60,7 @@ Same structured `files` result delivered as an NDJSON `result` event. `Do you want to overwrite existing files in supabase/migrations directory?` (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / - machine-output run takes the default (YES). + machine-output run takes the default (YES). An unrecognised answer declines. ## Notes diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index b54f72c94e..a512dece4a 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -254,6 +254,45 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); + it.live.each( + ["u", "yess", "nope", " "].flatMap((answer) => [ + { answer, isTTY: false }, + { answer, isTTY: true }, + ]), + )( + "cancels the overwrite on the unrecognised answer $answer (isTTY $isTTY)", + ({ answer, isTTY }) => { + const { layer } = setup(tmp.current, { + isTTY, + pipedInput: `${answer}\n`, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + const exit = yield* migrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); + } + expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each(["\n", "", " YES \n"])("overwrites on the piped answer %j", (pipedInput) => { + const { layer } = setup(tmp.current, { + isTTY: false, + pipedInput, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + yield* migrationFetch(flags()); + expect(yield* listMigrations(tmp.current)).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }); + it.live("bypasses the overwrite prompt with --yes (echoes the auto-answer)", () => { const { layer, out } = setup(tmp.current, { yes: true, diff --git a/apps/cli/src/commands/migration/migration.prompt.ts b/apps/cli/src/commands/migration/migration.prompt.ts index ab4bf3e628..bd74d62990 100644 --- a/apps/cli/src/commands/migration/migration.prompt.ts +++ b/apps/cli/src/commands/migration/migration.prompt.ts @@ -13,7 +13,8 @@ const NON_TTY_TIMEOUT_MILLIS = 100; * general `promptYesNo`: it writes the label to stderr and reads one stdin line * regardless of `--output` format (rather than auto-defaulting in json/stream-json), * and on a real TTY it reads a raw stdin line with a 10-minute timeout instead of a - * clack confirm UI. `--yes` short-circuits to `true`, echoing `<label> y`. + * clack confirm UI. `--yes` short-circuits to `true`, echoing `<label> y`. A parsed + * answer wins; an empty line, EOF, or timeout takes the default; any other line declines. */ export const migrationConfirm = ( title: string, @@ -33,6 +34,7 @@ export const migrationConfirm = ( yield* output.raw(label, "stderr"); const line = yield* stdin.readLine(stdin.isTTY ? TTY_TIMEOUT_MILLIS : NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); - if (!stdin.isTTY) yield* output.raw(`${input}\n`, "stderr"); - return parseYesNo(input) ?? options.defaultValue; + if (!stdin.isTTY) yield* output.raw(`${input.trim()}\n`, "stderr"); + // An unrecognised answer is never consent. + return parseYesNo(input) ?? (input.length > 0 ? false : options.defaultValue); }); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 9217298755..6c7a10d777 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -158,7 +158,8 @@ code or the rest of the payload. baseline to target the surviving **older** version, not the original squash target. - A failed full-schema dump leaves the target migration truncated (not recoverable — the file was already truncated before the dump began). -- A declined "Update remote migration history table?" prompt is a **success** path (exit 0, +- A declined "Update remote migration history table?" prompt (`n`, or any unrecognised + answer) is a **success** path (exit 0, no baseline query, `Finished …` still prints) — the opposite of `migration repair`/`fetch`/ `down`, which treat a decline as a cancellation. - **Atomicity note:** the old Go CLI sent the baseline `DELETE`/`INSERT` via a batched pipeline diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 74ea9d1ea6..64969f7aed 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1289,6 +1289,16 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("an unrecognised piped answer leaves the remote migration history alone", () => { + const s = setupRemote({ isTTY: false, pipedInput: "nope\n" }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* migrationSquash(flags()); + expect(s.execs).not.toContain("BEGIN"); + expect(s.queries).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "--version 0 baselines exactly version 0 even though a newer migration survives", () => { diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 829bcad068..21880b7ee5 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -65,7 +65,7 @@ export const pullHandler = (flags: PullFlags) => export const pullCommand = Command.make("pull", config).pipe( Command.withDescription( - "Refreshes local project state from a linked Supabase project or branch in one step: pulls config into supabase/config.toml, optionally fetches the remote migration history table, pulls the database schema into supabase/migrations (also updating that database's migration history), and downloads every Edge Function's source. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/n honored, default otherwise) — use --dry-run to preview first.", + "Refreshes local project state from a linked Supabase project or branch in one step: pulls config into supabase/config.toml, optionally fetches the remote migration history table, pulls the database schema into supabase/migrations (also updating that database's migration history), and downloads every Edge Function's source. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/yes/n/no honored, empty takes the default, anything else declines) — use --dry-run to preview first.", ), Command.withShortDescription("Pull remote project state into the local checkout"), Command.withExamples([ diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index 2ae7abf059..e60e86ce35 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -207,7 +207,7 @@ describe("storage rm", () => { }); }); - it.live("falls back to the default (no) on an unparseable piped answer", () => { + it.live("declines on an unparseable piped answer", () => { const { layer, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, diff --git a/apps/cli/src/shared/runtime/stdin.layer.ts b/apps/cli/src/shared/runtime/stdin.layer.ts index b08f84d98b..d8f70ff3ad 100644 --- a/apps/cli/src/shared/runtime/stdin.layer.ts +++ b/apps/cli/src/shared/runtime/stdin.layer.ts @@ -136,7 +136,7 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, // Outer `None` = timed out; inner `None` = EOF / read error; either way the // prompt takes its default. const line = yield* take.pipe(Effect.timeoutOption(Duration.millis(timeoutMillis))); - return Option.map(Option.flatten(line), (value) => value.trim()); + return Option.flatten(line); }); // Streams piped stdin without collecting it. Unlike `readPipedBytes`, read errors PROPAGATE diff --git a/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts b/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts index 3b0d35246b..a88f78ebe9 100644 --- a/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts @@ -152,6 +152,16 @@ describe("Stdin", () => { }); describe("readLine", () => { + it.effect("returns each physical line untrimmed", () => { + const layer = withStdin(Stream.fromIterable([encoder.encode(" \n y \n\n")])); + return Effect.gen(function* () { + const { readLine } = yield* Stdin; + expect(yield* readLine(10_000)).toEqual(Option.some(" ")); + expect(yield* readLine(10_000)).toEqual(Option.some(" y ")); + expect(yield* readLine(10_000)).toEqual(Option.some("")); + }).pipe(Effect.provide(layer)); + }); + it.effect("returns None at EOF", () => { const layer = withStdin(Stream.empty); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/runtime/stdin.service.ts b/apps/cli/src/shared/runtime/stdin.service.ts index 5445a9974a..83619a9bb5 100644 --- a/apps/cli/src/shared/runtime/stdin.service.ts +++ b/apps/cli/src/shared/runtime/stdin.service.ts @@ -20,7 +20,8 @@ interface StdinShape { readonly pipedBytesStream: Stream.Stream<Uint8Array, PlatformError>; readonly readPipedText: Effect.Effect<Option.Option<string>>; /** - * Reads the *next* line from stdin (trimmed), bounded by `timeoutMillis` — callers pass 10 + * Reads the *next* line from stdin (untrimmed, so a whitespace-only answer stays + * distinguishable from an empty one), bounded by `timeoutMillis` — callers pass 10 * minutes on a TTY and 100 ms otherwise. Backed by a single persistent, lazily-opened * reader, so successive calls return successive lines and a command that only prompts on a * TTY never grabs stdin before it needs to. A timeout, EOF, or a read error all return diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 4a00600a27..bbd0af32c5 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -89,15 +89,14 @@ export function mockStdin(isTTY: boolean, pipedInput?: string | Uint8Array): Lay ? Stream.fromIterable([pipedBytes.value]) : Stream.empty, readPipedText: Effect.succeed(pipedText), - // Ignores any timeout argument; dispenses piped lines one per call (trimmed), - // then None once exhausted. + // Ignores any timeout argument; dispenses piped lines one per call, then None once + // exhausted. readLine: () => Effect.sync(() => { if (lineIndex >= lines.length) { return Option.none<string>(); } - const line = (lines[lineIndex++] ?? "").trim(); - return line.length > 0 ? Option.some(line) : Option.none<string>(); + return Option.some(lines[lineIndex++] ?? ""); }), }); } From c877778f3394af39db88e75e1ed5225206e049ed Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:07:12 +0000 Subject: [PATCH 63/71] fix(cli): reject `test new` names that escape supabase/tests (CLI-2480) (#6790) ## TL;DR stops `supabase test new` from writing files outside `supabase/tests` when the name contains `..`. ## prob the name was joined onto `supabase/tests` with no containment check, so `test new ../../../escaped` wrote `escaped_test.sql` outside the project and exited 0. PS: `migration new` and `functions new` already reject names like this. ## sol the target is now checked against `supabase/tests` before anything is written, failing with `invalid test name` and counted as invalid input in telemetry. subdirectory names like `sub/foo` keep working. only names that escape now exit 1 where they used to succeed... ## ref - closes: https://github.com/supabase/cli/issues/6742 --- .../cli/src/commands/test/new/SIDE_EFFECTS.md | 28 +++++-- .../cli/src/commands/test/new/new.e2e.test.ts | 35 ++++++-- apps/cli/src/commands/test/new/new.errors.ts | 10 +++ apps/cli/src/commands/test/new/new.handler.ts | 17 +++- .../commands/test/new/new.integration.test.ts | 84 +++++++++++++++++++ .../telemetry/__fixtures__/error-tags.txt | 1 + 6 files changed, 159 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/commands/test/new/SIDE_EFFECTS.md b/apps/cli/src/commands/test/new/SIDE_EFFECTS.md index 0f7baec520..a196f4a04c 100644 --- a/apps/cli/src/commands/test/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/new/SIDE_EFFECTS.md @@ -8,11 +8,14 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------ | ------ | -------------------------------------- | -| `<workdir>/supabase/tests/<name>_test.sql` | SQL | always, unless the file already exists | +| Path | Format | When | +| ------------------------------------------ | ------ | ---------------------------------------------------------------------------- | +| `<workdir>/supabase/tests/<name>_test.sql` | SQL | if the name is valid, the file does not already exist, and creation succeeds | -The parent directory `<workdir>/supabase/tests/` is created if missing. +The parent directory `<workdir>/supabase/tests/` is created if missing. A name whose path, +with `..` segments collapsed, lands outside that directory is rejected before any +directory or file is created. The check is on the path text: an existing symlink under +`supabase/tests` is followed on purpose, so shared test folders keep working. ## API Routes @@ -28,11 +31,12 @@ The parent directory `<workdir>/supabase/tests/` is created if missing. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------- | -| `0` | success | -| `1` | test file already exists | -| `1` | write failure (e.g. permission denied) | +| Code | Condition | +| ---- | -------------------------------------------- | +| `0` | success | +| `1` | invalid test name (escapes `supabase/tests`) | +| `1` | test file already exists | +| `1` | write failure (e.g. permission denied) | ## Output @@ -55,3 +59,9 @@ Emits the same success payload as a final NDJSON `result` event. byte-identical to the original Go template). - `--template` / `-t` selects the template framework (only `pgtap` is supported; default `pgtap`). - Native TypeScript port (Phase 1+); no Go proxy. +- **Path-traversal hardening (TS-only):** the name is rejected before any write if + `<workdir>/supabase/tests/<name>_test.sql` lands outside the tests directory once + `..` segments are collapsed. Nothing is created — no file and no parent directory. + Existing symlinks under `supabase/tests` are followed on purpose (shared test + folders), so the check blocks `..` traversal only. Names that stay inside + `supabase/tests`, optionally with subdirectories, are unaffected. diff --git a/apps/cli/src/commands/test/new/new.e2e.test.ts b/apps/cli/src/commands/test/new/new.e2e.test.ts index 089a4abc9a..725cd15c46 100644 --- a/apps/cli/src/commands/test/new/new.e2e.test.ts +++ b/apps/cli/src/commands/test/new/new.e2e.test.ts @@ -6,12 +6,6 @@ import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -/** - * Golden-path e2e: `test new` writes a real file through the compiled-binary - * boundary. Validates `Command.provide` + the runtime layer + FileSystem wiring. - * Branch detail (json/stream-json, exists/write errors) is covered by the - * integration suite. - */ describe("supabase test new", () => { it.live( "scaffolds supabase/tests/<name>_test.sql and prints the created path", @@ -39,4 +33,33 @@ describe("supabase test new", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); + + it.live( + "rejects traversal without writing files or terminal controls", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectDir = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-test-new-rejected-e2e-", + }); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + 'project_id = "test-new-rejected-e2e"\n', + ); + + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["test", "new", "../../nested/\u001b[2Jx", "--output-format", "text"], + { cwd: projectDir, env: { NO_COLOR: "1", FORCE_COLOR: undefined } }, + ); + expect(exitCode, stderr).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain('invalid test name: "../../nested/[2Jx"'); + expect(stderr).not.toContain("\u001b"); + expect(yield* fs.exists(path.join(projectDir, "nested"))).toBe(false); + expect(yield* fs.exists(path.join(projectDir, "supabase", "tests"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/commands/test/new/new.errors.ts b/apps/cli/src/commands/test/new/new.errors.ts index 407b387acb..74f8003288 100644 --- a/apps/cli/src/commands/test/new/new.errors.ts +++ b/apps/cli/src/commands/test/new/new.errors.ts @@ -19,6 +19,16 @@ export class TestNewFileExistsError extends Data.TaggedError("TestNewFileExistsE } } +/** The test name resolves outside `supabase/tests`. */ +export class TestNewInvalidNameError extends Data.TaggedError("TestNewInvalidNameError")<{ + readonly path: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** Writing the test file failed (e.g. permission denied). */ export class TestNewWriteError extends Data.TaggedError("TestNewWriteError")<{ readonly path: string; diff --git a/apps/cli/src/commands/test/new/new.handler.ts b/apps/cli/src/commands/test/new/new.handler.ts index 05cc51eae8..339f7cf117 100644 --- a/apps/cli/src/commands/test/new/new.handler.ts +++ b/apps/cli/src/commands/test/new/new.handler.ts @@ -4,8 +4,13 @@ import { CommandSettings } from "../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { bold } from "../../../command-internal/colors.ts"; +import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import type { TestNewFlags } from "./new.command.ts"; -import { TestNewFileExistsError, TestNewWriteError } from "./new.errors.ts"; +import { + TestNewFileExistsError, + TestNewInvalidNameError, + TestNewWriteError, +} from "./new.errors.ts"; import { PGTAP_TEMPLATE } from "./new.template.ts"; const TEMPLATE_CONTENT: Record<"pgtap", string> = { @@ -27,6 +32,16 @@ export const testNew = Effect.fn("test.new")(function* (flags: TestNewFlags) { const relPath = path.join("supabase", "tests", `${flags.name}_test.sql`); const target = path.join(cliSettings.workdir, relPath); + // `path.join` collapses "..", so check the normalized target: names may include + // subdirectories as long as they resolve inside supabase/tests. + const testsDir = path.join(cliSettings.workdir, "supabase", "tests"); + if (!target.startsWith(testsDir + path.sep)) { + return yield* new TestNewInvalidNameError({ + path: relPath, + message: `invalid test name: "${sanitizeInlineName(flags.name)}" must not escape the ${path.join("supabase", "tests")} directory`, + }); + } + const exists = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); if (exists) { return yield* new TestNewFileExistsError({ diff --git a/apps/cli/src/commands/test/new/new.integration.test.ts b/apps/cli/src/commands/test/new/new.integration.test.ts index fba10382e2..98c885f832 100644 --- a/apps/cli/src/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/commands/test/new/new.integration.test.ts @@ -9,6 +9,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { TestNewInvalidNameError } from "./new.errors.ts"; import { PGTAP_TEMPLATE } from "./new.template.ts"; import { testNew } from "./new.handler.ts"; @@ -189,6 +191,88 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("creates test files under subdirectories that stay inside the tests directory", () => { + const { layer, out, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* testNew(flags("sub/foo")); + yield* testNew(flags("sub/../foo")); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests", "sub", "foo_test.sql"))).toBe( + true, + ); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests", "foo_test.sql"))).toBe(true); + expect(out.stdoutText).toContain("supabase/tests/sub/foo_test.sql"); + expect(out.stdoutText).toContain("supabase/tests/foo_test.sql"); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a name that escapes the tests directory and writes nothing", () => { + const { layer, telemetry, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Escapes into a fresh directory inside this test's own temp root, so a guard + // that ran after makeDirectory would leave `nested/` behind. + const exit = yield* Effect.exit(testNew(flags("../../nested/x"))); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(TestNewInvalidNameError); + expect(failure.value.message).toContain("must not escape the supabase/tests directory"); + } + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + }); + } + expect(yield* fs.exists(path.join(workdir, "nested"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests"))).toBe(false); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("follows an existing symlink to a shared test directory", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const testsDir = path.join(workdir, "supabase", "tests"); + const sharedDir = path.join(workdir, "shared-tests"); + yield* fs.makeDirectory(testsDir, { recursive: true }); + yield* fs.makeDirectory(sharedDir); + yield* fs.symlink(sharedDir, path.join(testsDir, "shared")); + + yield* testNew(flags("shared/pet")); + + expect(yield* fs.readFileString(path.join(sharedDir, "pet_test.sql"))).toBe(PGTAP_TEMPLATE); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a name that escapes into a sibling directory sharing the tests prefix", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* Effect.exit(testNew(flags("../tests2/x"))); + + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests2"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("sanitizes control characters in an invalid-name diagnostic", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const error = yield* testNew(flags("../../\u001b[2Jbad\r\n\t\u009bname")).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestNewInvalidNameError); + expect(error.message).toContain('invalid test name: "../../[2Jbad name"'); + }).pipe(Effect.provide(layer)); + }); + it.live("flushes telemetry via ensuring", () => { const { layer, telemetry } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index bbcc95c8ec..c89c548f1b 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -579,6 +579,7 @@ TestDbMutuallyExclusiveFlagsError TestDbNoTestsError TestDbRunError TestNewFileExistsError +TestNewInvalidNameError TestNewWriteError UnknownComputeExposureError UnknownComputeRuntimeError From 2b13026196177a2766bc8bca2fd4fd41435dca11 Mon Sep 17 00:00:00 2001 From: Julien Goux <hi@jgoux.dev> Date: Wed, 30 Sep 2026 16:16:21 +0000 Subject: [PATCH 64/71] fix(stack): give each Mailpit instance its own database (#6901) ## Summary The stack starts Mailpit without a database path, so Mailpit uses a temporary SQLite file named from the current time under the system temp directory and deletes it on every stop. Two native stacks waking mail together could end up on the same file, and one Mailpit exited with `database is locked (5) (SQLITE_BUSY)`; Auth could then not wake and `/signup` returned 502 in the native parallel-stacks e2e. Because Mail is a lazy member with an idle stop, captured emails also disappeared after a minute without traffic. - Process-recipe services can opt in to an owned instance directory (`<stack data root>/<instance id>`), claimed and removed through the same ownership marker logic the Database service uses, now shared in `services/InstanceRoot.ts`. Natively the recipe receives the host path; in containers the directory is mounted at `/instance`. - Mail opts in and sets `MP_DATABASE` to `mailpit.db` in that directory, so each instance has its own database, emails survive idle sleep and stop/start, and destroying the instance or stack removes them. --------- Co-authored-by: Julien Goux <Julien@supabase.io> --- packages/stack/src/services/Database.ts | 115 +++++------ .../services/InstanceRoot.integration.test.ts | 45 +++++ packages/stack/src/services/InstanceRoot.ts | 114 +++++++++++ .../src/services/Mail.integration.test.ts | 180 +++++++++++++++++- packages/stack/src/services/Mail.ts | 6 +- packages/stack/src/services/ProcessRecipe.ts | 46 ++++- 6 files changed, 426 insertions(+), 80 deletions(-) create mode 100644 packages/stack/src/services/InstanceRoot.integration.test.ts create mode 100644 packages/stack/src/services/InstanceRoot.ts diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index f88c06fa7c..dab2a348f2 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -64,6 +64,11 @@ import { type PasswdEntry, } from "../runtime/postgres-user.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; +import { + containerInstancePath, + ensureOwnedInstanceRoot, + removeOwnedInstanceRoot, +} from "./InstanceRoot.ts"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; import { instanceSnapshotsDirectory, @@ -352,82 +357,52 @@ const publishLogs = publishProcessLogs; const runtimeFromContainer = (process: ContainerProcess, discard: boolean): RuntimeSession => runtimeSessionFromContainer(process, describePostgresExit, { discard }); -const ensureOwnedRoot = Effect.fn("Database.ensureOwnedRoot")(( +const databaseOwnerFileName = ".supabase-database-owner.json"; + +const ensureOwnedRoot = ( fs: FileSystem.FileSystem, path: Path.Path, - root: string, + parentRoot: string, stackId: string, instanceId: string, -): Effect.Effect<void, DatabaseError> => { - const ownerFile = path.join(root, ".supabase-database-owner.json"); - const marker = JSON.stringify({ stackId, instanceId }); - return Effect.gen(function* () { - yield* fs - .makeDirectory(root, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - const present = yield* fs - .exists(ownerFile) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - if (present) { - const existing = yield* fs - .readFileString(ownerFile) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - if (existing !== marker) - return yield* databaseError("data", "Database root belongs to another instance"); - } else { - const entries = yield* fs - .readDirectory(root) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - if (entries.length > 0) - return yield* databaseError("data", "Database root is non-empty and unmarked"); - yield* fs - .writeFileString(ownerFile, marker, { mode: 0o600, flag: "wx" }) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - } - }); -}); +): Effect.Effect<void, DatabaseError> => + ensureOwnedInstanceRoot( + { + fs, + path, + parentRoot, + stackId, + instanceId, + ownerFileName: databaseOwnerFileName, + label: "Database root", + }, + databaseError, + ); -const removeOwnedRoot = Effect.fn("Database.removeOwnedRoot")(( +const removeOwnedRoot = ( fs: FileSystem.FileSystem, path: Path.Path, - root: string, + parentRoot: string, stackId: string, instanceId: string, removeData: Effect.Effect<void, ServiceError>, /** Root entries kept with the owner marker; when empty the root itself is removed. */ keep: ReadonlyArray<string> = [], -): Effect.Effect<void, ServiceError> => { - const ownerFile = path.join(root, ".supabase-database-owner.json"); - const marker = JSON.stringify({ stackId, instanceId }); - return Effect.gen(function* () { - const present = yield* fs - .exists(ownerFile) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - if (!present) { - if (yield* fs.exists(root).pipe(Effect.mapError((cause) => errorFor("destroy", cause)))) - return yield* errorFor("destroy", "Database root is unmarked"); - return; - } - const existing = yield* fs - .readFileString(ownerFile) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - if (existing !== marker) - return yield* errorFor("destroy", "Database root belongs to another instance"); - yield* removeData; - if (keep.length === 0) - return yield* fs - .remove(root, { recursive: true, force: true }) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - const names = yield* fs - .readDirectory(root) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - for (const name of names) - if (name !== path.basename(ownerFile) && !keep.includes(name)) - yield* fs - .remove(path.join(root, name), { recursive: true, force: true }) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - }); -}); +): Effect.Effect<void, ServiceError> => + removeOwnedInstanceRoot( + { + fs, + path, + parentRoot, + stackId, + instanceId, + ownerFileName: databaseOwnerFileName, + label: "Database root", + }, + removeData, + errorFor, + keep, + ); const nativeProcess = ( artifact: PreparedNativeArtifact, @@ -505,10 +480,8 @@ export const makeDatabase = ( const prepared = yield* Ref.make<ReadonlyMap<string, PreparedNativeArtifact>>(new Map()); if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(String(options.stackId))) return yield* databaseError("identity", "Invalid stack id"); - if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(options.instanceId)) - return yield* databaseError("identity", "Invalid instance id"); + yield* ensureOwnedRoot(fs, path, options.root, String(options.stackId), options.instanceId); const instanceRoot = path.join(options.root, options.instanceId); - yield* ensureOwnedRoot(fs, path, instanceRoot, String(options.stackId), options.instanceId); const container: ContainerRuntime | undefined = options.runtime === "native" ? undefined @@ -573,7 +546,7 @@ export const makeDatabase = ( env: {}, mounts: [ storage === undefined - ? { source: instanceRoot, target: "/instance", readOnly: false } + ? { source: instanceRoot, target: containerInstancePath, readOnly: false } : yield* storage.mount(version).pipe( Effect.map((mount) => ({ ...mount, target: "/var/lib/postgresql/data" })), Effect.mapError((cause) => errorFor("data", cause)), @@ -955,7 +928,7 @@ export const makeDatabase = ( removeOwnedRoot( fs, path, - instanceRoot, + options.root, String(options.stackId), options.instanceId, Effect.gen(function* () { @@ -976,7 +949,7 @@ export const makeDatabase = ( ? removeOwnedRoot( fs, path, - instanceRoot, + options.root, String(options.stackId), options.instanceId, Effect.void, @@ -985,7 +958,7 @@ export const makeDatabase = ( : storage.removeData(postgresVersion(context.config.version)); return clear.pipe( Effect.andThen( - ensureOwnedRoot(fs, path, instanceRoot, String(options.stackId), options.instanceId), + ensureOwnedRoot(fs, path, options.root, String(options.stackId), options.instanceId), ), Effect.mapError((cause) => errorFor("reset", cause)), ); diff --git a/packages/stack/src/services/InstanceRoot.integration.test.ts b/packages/stack/src/services/InstanceRoot.integration.test.ts new file mode 100644 index 0000000000..62b9c2fe65 --- /dev/null +++ b/packages/stack/src/services/InstanceRoot.integration.test.ts @@ -0,0 +1,45 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { ensureOwnedInstanceRoot, type OwnedInstanceRootParams } from "./InstanceRoot.ts"; + +class TestInstanceRootError extends Data.TaggedError("TestInstanceRootError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +const onError = (operation: string, cause: unknown) => + new TestInstanceRootError({ operation, cause }); + +const run = <A, E, R>(effect: Effect.Effect<A, E, R>) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("InstanceRoot", () => { + it.live("lets concurrent first claims of a fresh root all succeed", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentRoot = yield* fs.makeTempDirectoryScoped({ prefix: "instance-root-race-" }); + const params: OwnedInstanceRootParams = { + fs, + path, + parentRoot, + stackId: "stack", + instanceId: "instance", + ownerFileName: ".supabase-instance-owner.json", + label: "Instance root", + }; + const results = yield* Effect.all( + Array.from({ length: 10 }, () => Effect.exit(ensureOwnedInstanceRoot(params, onError))), + { concurrency: "unbounded" }, + ); + expect(results.every(Exit.isSuccess)).toBe(true); + const marker = yield* fs.readFileString( + path.join(parentRoot, "instance", ".supabase-instance-owner.json"), + ); + expect(marker).toBe('{"stackId":"stack","instanceId":"instance"}'); + }), + ), + ); +}); diff --git a/packages/stack/src/services/InstanceRoot.ts b/packages/stack/src/services/InstanceRoot.ts new file mode 100644 index 0000000000..49d7a998d1 --- /dev/null +++ b/packages/stack/src/services/InstanceRoot.ts @@ -0,0 +1,114 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +/** The container mount target for an instance-scoped directory owned on the host. */ +export const containerInstancePath = "/instance"; + +const safeInstanceIdPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u; + +/** True when `instanceId` is safe to join to a root as one path segment, without traversal. */ +const isSafeInstanceId = (instanceId: string): boolean => safeInstanceIdPattern.test(instanceId); + +export interface OwnedInstanceRootParams { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + /** Parent directory the instance-scoped root is joined under, as `<parentRoot>/<instanceId>`. */ + readonly parentRoot: string; + readonly stackId: string; + readonly instanceId: string; + readonly ownerFileName: string; + /** Names the owned instance root in error messages, e.g. "Database root". */ + readonly label: string; +} + +/** Claims `<parentRoot>/<instanceId>` for one stack+instance pair, failing if another instance already owns it. */ +export const ensureOwnedInstanceRoot = Effect.fn("InstanceRoot.ensureOwnedInstanceRoot")(<E>( + params: OwnedInstanceRootParams, + onError: (operation: string, cause: unknown) => E, +): Effect.Effect<void, E> => { + const { fs, path, parentRoot, stackId, instanceId, ownerFileName, label } = params; + const root = path.join(parentRoot, instanceId); + const ownerFile = path.join(root, ownerFileName); + const marker = JSON.stringify({ stackId, instanceId }); + const claimExistingMarker = Effect.gen(function* () { + const existing = yield* fs + .readFileString(ownerFile) + .pipe(Effect.mapError((cause) => onError("data", cause))); + if (existing !== marker) + return yield* Effect.fail(onError("data", `${label} belongs to another instance`)); + }); + return Effect.gen(function* () { + if (!isSafeInstanceId(instanceId)) + return yield* Effect.fail(onError("data", `${label} instance id is not a safe path segment`)); + yield* fs + .makeDirectory(root, { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => onError("data", cause))); + const present = yield* fs + .exists(ownerFile) + .pipe(Effect.mapError((cause) => onError("data", cause))); + if (present) return yield* claimExistingMarker; + const entries = yield* fs + .readDirectory(root) + .pipe(Effect.mapError((cause) => onError("data", cause))); + if (entries.length > 0) { + // A concurrent first claim may have written the marker between the `exists` and + // `readDirectory` calls above; an otherwise-empty root is still safe to compare. + if (entries.length === 1 && entries[0] === ownerFileName) return yield* claimExistingMarker; + return yield* Effect.fail(onError("data", `${label} is non-empty and unmarked`)); + } + yield* fs.writeFileString(ownerFile, marker, { mode: 0o600, flag: "wx" }).pipe( + // A concurrent first claim may win the exclusive create; the loser re-reads the marker + // instead of failing, since both wrote the same stack+instance marker. + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => claimExistingMarker, + ), + Effect.catchTag("PlatformError", (cause) => Effect.fail(onError("data", cause))), + ); + }); +}); + +/** Removes `<parentRoot>/<instanceId>` for one stack+instance pair after running `removeData`, keeping any `keep` entries. */ +export const removeOwnedInstanceRoot = Effect.fn("InstanceRoot.removeOwnedInstanceRoot")(<E>( + params: OwnedInstanceRootParams, + removeData: Effect.Effect<void, E>, + onError: (operation: string, cause: unknown) => E, + /** Root entries kept with the owner marker; when empty the root itself is removed. */ + keep: ReadonlyArray<string> = [], +): Effect.Effect<void, E> => { + const { fs, path, parentRoot, stackId, instanceId, ownerFileName, label } = params; + const root = path.join(parentRoot, instanceId); + const ownerFile = path.join(root, ownerFileName); + const marker = JSON.stringify({ stackId, instanceId }); + return Effect.gen(function* () { + if (!isSafeInstanceId(instanceId)) + return yield* Effect.fail( + onError("destroy", `${label} instance id is not a safe path segment`), + ); + const present = yield* fs + .exists(ownerFile) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + if (!present) { + if (yield* fs.exists(root).pipe(Effect.mapError((cause) => onError("destroy", cause)))) + return yield* Effect.fail(onError("destroy", `${label} is unmarked`)); + return; + } + const existing = yield* fs + .readFileString(ownerFile) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + if (existing !== marker) + return yield* Effect.fail(onError("destroy", `${label} belongs to another instance`)); + yield* removeData; + if (keep.length === 0) + return yield* fs + .remove(root, { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + const names = yield* fs + .readDirectory(root) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + for (const name of names) + if (name !== path.basename(ownerFile) && !keep.includes(name)) + yield* fs + .remove(path.join(root, name), { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + }); +}); diff --git a/packages/stack/src/services/Mail.integration.test.ts b/packages/stack/src/services/Mail.integration.test.ts index eea7be5eff..f80d9a3073 100644 --- a/packages/stack/src/services/Mail.integration.test.ts +++ b/packages/stack/src/services/Mail.integration.test.ts @@ -1,7 +1,7 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer } from "effect"; -import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { makeService } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -18,6 +18,42 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); +interface MailpitMessages { + readonly total: number; + readonly messages: ReadonlyArray<{ readonly Subject: string }>; +} + +const fetchMessages = (endpoint: { readonly host?: string; readonly port: number }) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const host = endpoint.host ?? "127.0.0.1"; + const response = yield* client.execute( + HttpClientRequest.get(`http://${host}:${endpoint.port}/api/v1/messages`), + ); + return (yield* response.json) as unknown as MailpitMessages; + }); + +/** Sends a test email through Mailpit's HTTP send API rather than a raw SMTP session. */ +const sendTestEmail = ( + endpoint: { readonly host?: string; readonly port: number }, + subject: string, +) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const host = endpoint.host ?? "127.0.0.1"; + const response = yield* client.execute( + HttpClientRequest.post(`http://${host}:${endpoint.port}/api/v1/send`).pipe( + HttpClientRequest.bodyJsonUnsafe({ + From: { Email: "sender@example.com" }, + To: [{ Email: "recipient@example.com" }], + Subject: subject, + Text: "body", + }), + ), + ); + yield* HttpClientResponse.filterStatusOk(response); + }); + describe("service catalog", () => { it.live("launches the real Mailpit recipe and serves its HTTP endpoint", () => Effect.scoped( @@ -44,4 +80,144 @@ describe("service catalog", () => { }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + + it.live( + "keeps two native mail instances on one isolated database each, both becoming ready", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-isolation-" }); + const recipeFor = (instanceId: string) => + makeServiceRecipe({ service: "mail", config: {} }, { ...options(root), instanceId }); + const first = yield* recipeFor("mail-a"); + const second = yield* recipeFor("mail-b"); + const firstInstance = yield* makeService(first.definition, { + id: "mail-a", + config: first.creation, + }); + const secondInstance = yield* makeService(second.definition, { + id: "mail-b", + config: second.creation, + }); + yield* Effect.all([firstInstance.start, secondInstance.start], { + concurrency: "unbounded", + }); + yield* Effect.all([firstInstance.ready, secondInstance.ready], { + concurrency: "unbounded", + }); + expect(yield* fs.exists(path.join(root, "mail-a", "mailpit.db"))).toBe(true); + expect(yield* fs.exists(path.join(root, "mail-b", "mailpit.db"))).toBe(true); + yield* Effect.all([firstInstance.stop, secondInstance.stop], { + concurrency: "unbounded", + }); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("keeps a captured email after a native mail stop and start", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-persistence-" }); + const recipe = yield* makeServiceRecipe({ service: "mail", config: {} }, options(root)); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready; + yield* sendTestEmail(yield* recipe.endpoint("http"), "persistence-test"); + const beforeRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(beforeRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test", + ); + yield* instance.stop; + yield* instance.start; + yield* instance.ready; + const afterRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(afterRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test", + ); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("keeps a captured email after a Docker mail stop and start", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "catalog-mail-persistence-docker-", + }); + const recipe = yield* makeServiceRecipe( + { service: "mail", config: {} }, + dockerOptions(root), + ); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready; + yield* sendTestEmail(yield* recipe.endpoint("http"), "persistence-test-docker"); + const beforeRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(beforeRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test-docker", + ); + yield* instance.stop; + yield* instance.start; + yield* instance.ready; + const afterRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(afterRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test-docker", + ); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("removes the native mail instance directory when destroyed", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-destroy-" }); + const recipe = yield* makeServiceRecipe({ service: "mail", config: {} }, options(root)); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready; + const instanceRoot = path.join(root, "instance"); + expect(yield* fs.exists(instanceRoot)).toBe(true); + yield* instance.destroy; + expect(yield* fs.exists(instanceRoot)).toBe(false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("rejects a traversal instance id and creates nothing outside the root", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-traversal-" }); + const recipe = yield* makeServiceRecipe( + { service: "mail", config: {} }, + { ...options(root), instanceId: "../escaped" }, + ); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + const exit = yield* Effect.exit(instance.start); + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* fs.exists(path.join(root, "..", "escaped"))).toBe(false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); }); diff --git a/packages/stack/src/services/Mail.ts b/packages/stack/src/services/Mail.ts index a1b535ddfd..3f7ef84ac0 100644 --- a/packages/stack/src/services/Mail.ts +++ b/packages/stack/src/services/Mail.ts @@ -21,7 +21,10 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ executable: "bin/mailpit", ports: { http: 8025, smtp: 1025, pop3: 1110 }, healthPath: "/readyz", - env: (_creation, endpoints, container) => { + // Mailpit's default /tmp temp-file name collides across parallel native instances, and it + // deletes that file on every stop regardless of runtime, losing captured mail. + instanceDirectory: true, + env: (_creation, endpoints, container, instanceDir) => { const http = endpoints.get("http"); const smtp = endpoints.get("smtp"); const pop3 = endpoints.get("pop3"); @@ -46,6 +49,7 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ }), // Docker gateway addresses can make SMTP reverse DNS delay Auth recovery. MP_SMTP_DISABLE_RDNS: "true", + ...(instanceDir === undefined ? {} : { MP_DATABASE: `${instanceDir}/mailpit.db` }), }); }, args: () => Effect.succeed([]), diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 3b8265e360..73a81799b9 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -41,6 +41,11 @@ import { runtimeSessionFromContainer, } from "../runtime/Session.ts"; import { ServiceError, ServiceLaunchError, type RuntimeSession } from "../Service.ts"; +import { + containerInstancePath, + ensureOwnedInstanceRoot, + removeOwnedInstanceRoot, +} from "./InstanceRoot.ts"; import { type CatalogLog, CatalogError, @@ -77,6 +82,8 @@ export interface ProcessRecipeSpec<C extends RecipeCreation<ServiceKind, unknown creation: C, endpoints: ReadonlyMap<string, ServiceEndpoint>, container: boolean, + /** The claimed instance directory: the host path natively, `/instance` in a container. */ + instanceDir?: string, ) => Effect.Effect<Readonly<Record<string, string>>, ServiceError>; readonly nativeStartupEnv?: ( creation: C, @@ -96,6 +103,8 @@ export interface ProcessRecipeSpec<C extends RecipeCreation<ServiceKind, unknown readonly containerEntrypoint?: (creation: C) => string | undefined; readonly prepare?: (creation: C) => Effect.Effect<void, ServiceError>; readonly removeData?: (creation: C) => Effect.Effect<void, ServiceError>; + /** Claims an owned instance directory, mounted at `/instance` in containers. */ + readonly instanceDirectory?: boolean; } export interface ResolvedStartupCommand { @@ -409,8 +418,23 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> const preparedRoot = yield* Ref.make<string | undefined>(undefined); const endpoints = yield* Ref.make<ReadonlyMap<string, ServiceEndpoint>>(new Map()); const logs = yield* PubSub.sliding<CatalogLog>(256); + const instanceRoot = deps.path.join(options.root, options.instanceId); + const ownedInstanceRoot = { + fs: deps.fs, + path: deps.path, + parentRoot: options.root, + stackId: options.stackId, + instanceId: options.instanceId, + ownerFileName: ".supabase-instance-owner.json", + label: "Instance root", + }; + const nativeInstanceDir = spec.instanceDirectory === true ? instanceRoot : undefined; + const containerInstanceDir = + spec.instanceDirectory === true ? containerInstancePath : undefined; const prepare = Effect.fn("ProcessRecipe.prepare")(function* (candidate: C) { + if (spec.instanceDirectory === true) + yield* ensureOwnedInstanceRoot(ownedInstanceRoot, serviceError); if (spec.prepare !== undefined) yield* spec.prepare(candidate); const resolved = yield* resolveArtifact({ service: candidate.service, @@ -547,7 +571,7 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> container: false, artifactRoot, }); - const env = yield* spec.env(context.config, selected, false); + const env = yield* spec.env(context.config, selected, false, nativeInstanceDir); yield* Scope.close(reservation.portScope, Exit.void); const native: NativeProcess = yield* spawnNativeProcess( { @@ -784,10 +808,15 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> instanceId: options.instanceId, service: spec.service, project: options.project, - env: yield* spec.env(context.config, containerDesired, true), + env: yield* spec.env(context.config, containerDesired, true, containerInstanceDir), entrypoint: spec.containerEntrypoint?.(context.config), args: yield* spec.args(context.config, containerDesired, { container: true }), - mounts: yield* spec.mounts(context.config, { container: true }), + mounts: [ + ...(yield* spec.mounts(context.config, { container: true })), + ...(spec.instanceDirectory === true + ? [{ source: instanceRoot, target: containerInstancePath, readOnly: false }] + : []), + ], ports: [...containerDesired.values()].map((endpoint) => endpoint.port), }) .pipe( @@ -840,9 +869,14 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> prepare, launch, removeData: (context) => - (spec.removeData?.(context.config) ?? Effect.void).pipe( - Effect.andThen(Ref.set(endpoints, new Map())), - ), + (spec.instanceDirectory === true + ? removeOwnedInstanceRoot( + ownedInstanceRoot, + spec.removeData?.(context.config) ?? Effect.void, + serviceError, + ) + : (spec.removeData?.(context.config) ?? Effect.void) + ).pipe(Effect.andThen(Ref.set(endpoints, new Map()))), }, endpoints, logs: Stream.fromPubSub(logs).pipe( From bb809cf8f47cf8c847131c3b076d87c78a584e1b Mon Sep 17 00:00:00 2001 From: Julien Goux <hi@jgoux.dev> Date: Wed, 30 Sep 2026 16:16:46 +0000 Subject: [PATCH 65/71] chore(stack): log lazy wakes instead of bounding them in the proxy (#6909) ## Summary When a lazy member's wake stalled behind a TCP endpoint, a client saw an accepted connection that never answered until its own timeout fired, and whole-stack e2e failure diagnostics only showed child-service output, so nothing said where the wake stopped. This surfaced as a rare `PgClient: Connection timed out` against the pooler after a stack reopen in the native lifecycle e2e. - The orchestrator logs when a wake is requested, with the service and trigger, and when the member is ready, started without awaiting readiness, or fails to wake. - The TCP proxy logs an error naming the endpoint when waking or connecting to its target fails. Transport resets after a successful connect are not logged as failures. - Whole-stack failure diagnostics include the tail of the stack owner's log. The proxies still wait for a wake without a bound of their own: a wake includes preparation (image pulls) and prerequisite startup, which already have their own budgets, and a single proxy-side timeout could interrupt a legitimate cold start. --- .../stack/src/Commands.integration.test.ts | 2 + .../stack/src/HttpProxy.integration.test.ts | 11 +- packages/stack/src/Network.ts | 2 +- .../src/Orchestrator.integration.test.ts | 148 ++++++++++++++++++ packages/stack/src/Orchestrator.ts | 59 ++++++- packages/stack/src/Owner.ts | 19 ++- packages/stack/src/Proxy.integration.test.ts | 89 ++++++++++- packages/stack/src/Proxy.ts | 11 +- .../src/composition/Supabase.unit.test.ts | 1 + packages/stack/tests/docker-relay.ts | 2 +- packages/stack/tests/logs.ts | 10 ++ packages/stack/tests/whole-stack/fixture.ts | 28 +++- 12 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 packages/stack/tests/logs.ts diff --git a/packages/stack/src/Commands.integration.test.ts b/packages/stack/src/Commands.integration.test.ts index aaee340e01..7e6d3b7a36 100644 --- a/packages/stack/src/Commands.integration.test.ts +++ b/packages/stack/src/Commands.integration.test.ts @@ -106,6 +106,7 @@ describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { Effect.succeed( endpoint.kind === "unix" ? { path: `${endpoint.path}/.s.PGSQL.5432` } : endpoint, ), + "database:sql", ).pipe(Effect.forkScoped); const runner = yield* makeTestCommandRunner({ root, cacheRoot, stackId, runtime }); const env = { @@ -252,6 +253,7 @@ describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { Effect.succeed( endpoint.kind === "unix" ? { path: `${endpoint.path}/.s.PGSQL.5432` } : endpoint, ), + "database:sql", ).pipe(Effect.forkScoped); const runner = yield* makeTestCommandRunner({ root, cacheRoot, stackId, runtime }); const env = { diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index 3123e22a91..bec4715c09 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -1,11 +1,12 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Data, Deferred, Effect, Fiber, Layer, Logger, type LogLevel } from "effect"; +import { Data, Deferred, Effect, Fiber, Layer } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { createServer, type Server, type ServerResponse } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw server fixture. import { createServer as createTcpServer, Socket, type Server as NetServer } from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw disconnect fixture. // oxlint-disable-next-line effecttsgo/node-builtin-import -- raw WebSocket upgrade fixture. import { WebSocket, WebSocketServer } from "ws"; +import { captureLogs } from "../tests/logs.ts"; import { ProxyError } from "./Proxy.ts"; import { makeHttpProxy, type HttpRoute } from "./HttpProxy.ts"; @@ -36,14 +37,6 @@ class HttpProxyTestError extends Data.TaggedError("HttpProxyTestError")<{ readonly cause?: unknown; }> {} -const captureLogs = (levels: ReadonlyArray<LogLevel.LogLevel>) => (lines: Array<string>) => - Logger.layer([ - Logger.make(({ logLevel, message }) => { - if (levels.some((level) => level === logLevel)) - lines.push((Array.isArray(message) ? message : [message]).map(String).join(" ")); - }), - ]); - const captureErrors = captureLogs(["Error"]); /** Upstream that resets its first `drops` accepted connections without responding. */ diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index 74d8381531..bb80763509 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -185,7 +185,7 @@ const makeNetwork = (options: { } const listener = yield* bindTcp(host, port); yield* Effect.forkIn( - serveTcp(listener, endpoint.backend).pipe( + serveTcp(listener, endpoint.backend, `${id}:${name}`).pipe( Effect.provideService(Scope.Scope, endpointScope), Effect.catch((cause) => Effect.logWarning("Public listener failed", cause), diff --git a/packages/stack/src/Orchestrator.integration.test.ts b/packages/stack/src/Orchestrator.integration.test.ts index 72dddf8bde..1fe51757ff 100644 --- a/packages/stack/src/Orchestrator.integration.test.ts +++ b/packages/stack/src/Orchestrator.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Schema, Scope, Stream } from "effect"; import * as TestClock from "effect/testing/TestClock"; +import { captureLogs } from "../tests/logs.ts"; import * as Orchestrator from "./Orchestrator.ts"; import type { RegisteredInstance } from "./Orchestrator.ts"; import { makeService, ServiceError } from "./Service.ts"; @@ -57,6 +58,7 @@ const makeInstance = ( ); const instance: RegisteredInstance = { id, + service: id, core, startAt: (revision, inputs, wake, guard) => core.startAt(revision, inputs, wake, guard), restart: (revision, inputs, candidate, guard) => @@ -783,6 +785,152 @@ it.live("allows later traffic to retry an armed service after a failed wake laun ), ); +it.live("logs a named failure for a failed wake and readiness for a successful one", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const failing = yield* Ref.make(true); + yield* makeInstance(orchestrator, "api", { + launch: Ref.get(failing).pipe( + Effect.flatMap((value) => (value ? Effect.fail(failure("launch failed")) : Effect.void)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + yield* Effect.scoped(orchestrator.acquire("api")).pipe(Effect.flip); + yield* Ref.set(failing, false); + yield* Effect.scoped(orchestrator.acquire("api")); + yield* orchestrator.stopNamespace; + expect(logs.some((line) => line.includes("api api failed to wake"))).toBe(true); + expect(logs.some((line) => line.includes("api api is ready"))).toBe(true); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("logs exactly one wake for many concurrent acquires of a sleeping member", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const launching = yield* Deferred.make<void>(); + const proceed = yield* Deferred.make<void>(); + yield* makeInstance(orchestrator, "api", { + launch: Deferred.succeed(launching, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + const callers = yield* Effect.forEach(Array.from({ length: 20 }), () => + Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ), + ); + yield* Deferred.await(launching); + yield* Deferred.succeed(proceed, undefined); + yield* Effect.forEach(callers, Fiber.join); + yield* orchestrator.stopNamespace; + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(1); + expect(logs.filter((line) => line.includes("api api is ready"))).toHaveLength(1); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("logs a distinct failure when a launched member never becomes ready", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + yield* makeInstance(orchestrator, "api", { + health: Effect.fail(failure("still booting")), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + yield* Effect.scoped(orchestrator.acquire("api")).pipe(Effect.flip); + yield* orchestrator.stopNamespace; + expect(logs.some((line) => line.includes("api api failed to become ready"))).toBe(true); + expect(logs.some((line) => line.includes("api api failed to wake"))).toBe(false); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("logs no extra wake while the initiator is still waiting for readiness", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const healthEntered = yield* Deferred.make<void>(); + const readyGate = yield* Deferred.make<void>(); + yield* makeInstance(orchestrator, "api", { + health: Deferred.succeed(healthEntered, undefined).pipe( + Effect.andThen(Deferred.await(readyGate)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + const first = yield* Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(healthEntered); + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(1); + const second = yield* Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(readyGate, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + yield* orchestrator.stopNamespace; + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(1); + expect(logs.filter((line) => line.includes("api api is ready"))).toHaveLength(1); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("does not log a spurious wake for a member already starting outside acquire", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const launching = yield* Deferred.make<void>(); + const proceed = yield* Deferred.make<void>(); + yield* makeInstance(orchestrator, "api", { + launch: Deferred.succeed(launching, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "eager" }], + dependencies: [], + }); + const composition = yield* orchestrator.startComposition.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(launching); + const acquired = yield* Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(composition); + yield* Fiber.join(acquired); + yield* orchestrator.stopNamespace; + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(0); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + describe("readiness recovery", () => { const recoverableHealth = (healthy: Ref.Ref<boolean>) => Ref.get(healthy).pipe( diff --git a/packages/stack/src/Orchestrator.ts b/packages/stack/src/Orchestrator.ts index 4c136d4269..b00639164e 100644 --- a/packages/stack/src/Orchestrator.ts +++ b/packages/stack/src/Orchestrator.ts @@ -67,6 +67,8 @@ interface RegisteredCore { export interface RegisteredInstance { readonly id: string; + /** The service kind this instance runs, for wake observability; the id is the graph identity. */ + readonly service: string; readonly core: RegisteredCore; readonly startAt: ( revision: number, @@ -177,6 +179,7 @@ export interface Interface<Entry extends RegisteredInstance = RegisteredInstance readonly acquire: ( id: string, awaitReady?: boolean, + trigger?: string, ) => Effect.Effect<void, OrchestratorError | LifecycleError, Scope.Scope>; } @@ -233,6 +236,8 @@ export const make = Effect.fn("Orchestrator.make")(function* < const registry = yield* Ref.make<ReadonlyMap<string, Entry>>(new Map()); const composition = yield* Ref.make<CompositionConfig>({ members: [], dependencies: [] }); const activity = yield* Ref.make<ReadonlyMap<string, ActivityState>>(new Map()); + /** Tracks members with a wake in progress so only its initiating caller logs the transition. */ + const wakes = yield* Ref.make<ReadonlySet<string>>(new Set()); const withGraph = Effect.fn("Orchestrator.withGraph")(<A, E>(effect: Effect.Effect<A, E>) => Effect.uninterruptibleMask((restore) => @@ -884,12 +889,12 @@ export const make = Effect.fn("Orchestrator.make")(function* < restartComposition: restartComposition(), stopNamespace: stopNamespace(), destroyNamespace: destroyNamespace(), - acquire: Effect.fn("Orchestrator.acquire")((id, awaitReady = true) => + acquire: Effect.fn("Orchestrator.acquire")((id, awaitReady = true, trigger) => Effect.gen(function* () { const instance = yield* node(id); const scope = yield* Scope.Scope; const now = yield* Clock.currentTimeMillis; - const wake = yield* withGraph( + const { wake, initiator } = yield* withGraph( Effect.gen(function* () { const observation = yield* instance.core.get; if (observation.lifecycle === "stopped" && !observation.wakeEnabled) @@ -909,15 +914,55 @@ export const make = Effect.fn("Orchestrator.make")(function* < Effect.ignore, ), ); - return observation.lifecycle !== "running"; + // A "starting" observer is always joining an in-flight start (a wake or an + // eager/explicit start), never its initiator, regardless of the marker below. + if (observation.lifecycle === "running" || observation.lifecycle === "starting") + return { wake: observation.lifecycle === "starting", initiator: false } as const; + const inFlight = yield* Ref.get(wakes); + const initiator = !inFlight.has(id); + if (initiator) yield* Ref.set(wakes, new Set(inFlight).add(id)); + return { wake: true, initiator } as const; }), ); if (wake) { - const plan = yield* snapshotPlan(id); - for (const member of plan.order) yield* (yield* node(member)).bind; - yield* startNode(id, true, awaitReady, plan); + if (initiator) + yield* Effect.logInfo( + `Waking ${instance.service} ${id}${trigger === undefined ? "" : ` (${trigger})`}`, + ); + yield* Effect.gen(function* () { + const plan = yield* snapshotPlan(id); + for (const member of plan.order) yield* (yield* node(member)).bind; + yield* startNode(id, true, false, plan).pipe( + Effect.tapError((cause) => + initiator + ? Effect.logError(`${instance.service} ${id} failed to wake`, cause) + : Effect.void, + ), + ); + }).pipe( + Effect.ensuring( + initiator + ? Ref.update(wakes, (ids) => { + const next = new Set(ids); + next.delete(id); + return next; + }) + : Effect.void, + ), + ); + if (!awaitReady && initiator) + yield* Effect.logInfo(`${instance.service} ${id} started (readiness not awaited)`); + } + if (awaitReady) { + yield* instance.core.ready.pipe( + Effect.tapError((cause) => + wake && initiator + ? Effect.logError(`${instance.service} ${id} failed to become ready`, cause) + : Effect.void, + ), + ); + if (wake && initiator) yield* Effect.logInfo(`${instance.service} ${id} is ready`); } - if (awaitReady) yield* instance.core.ready; }), ), }; diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index 62cfee1a72..b36d9fcfa1 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -363,15 +363,17 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { const endpoint: NetworkEndpoint = { protocol: name === "http" ? "http" : "tcp", port: endpointPort(initial, name), - backend: orchestrator.acquire(id, name !== "inspector").pipe( - Effect.andThen(recipe.endpoint(name)), - Effect.flatMap(backendAddress), - Effect.mapError((cause) => - cause instanceof ProxyError - ? cause - : new ProxyError({ message: cause.message, cause }), + backend: orchestrator + .acquire(id, name !== "inspector", `traffic on endpoint ${name}`) + .pipe( + Effect.andThen(recipe.endpoint(name)), + Effect.flatMap(backendAddress), + Effect.mapError((cause) => + cause instanceof ProxyError + ? cause + : new ProxyError({ message: cause.message, cause }), + ), ), - ), enabled, ...(shared === undefined ? {} : { shared }), ...(join === undefined ? {} : { join }), @@ -383,6 +385,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { yield* Ref.set(namespaceRef, namespace); const entry: Entry = { id, + service: initial.service, core, recipe, creation, diff --git a/packages/stack/src/Proxy.integration.test.ts b/packages/stack/src/Proxy.integration.test.ts index db73663b91..e08b81eaa2 100644 --- a/packages/stack/src/Proxy.integration.test.ts +++ b/packages/stack/src/Proxy.integration.test.ts @@ -9,7 +9,12 @@ import { } from "effect/unstable/http"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.make requires a native server factory. import * as Http from "node:http"; -import { bindTcp, serveTcp } from "./Proxy.ts"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- raw client and backend fixtures for connection failures. +import { createServer, Socket, type Server } from "node:net"; +import { captureLogs } from "../tests/logs.ts"; +import { bindTcp, serveTcp, ProxyError } from "./Proxy.ts"; + +const captureErrors = captureLogs(["Error"]); it.live( "waits for target readiness and forwards a streamed response through its retained TCP listener", @@ -41,7 +46,7 @@ it.live( const target = Effect.acquireRelease(Deferred.succeed(acquired, undefined), () => Deferred.succeed(released, undefined), ).pipe(Effect.andThen(Deferred.await(ready)), Effect.as(address)); - yield* serveTcp(listener, target).pipe(Effect.forkScoped); + yield* serveTcp(listener, target, "backend").pipe(Effect.forkScoped); const body = new Uint8Array(2 * 1024 * 1024).fill(71); const client = yield* HttpClient.HttpClient; const request = client @@ -64,3 +69,83 @@ it.live( }), ).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)), ); + +const connectAndAwaitClose = (port: number) => + Effect.callback<void, never>((resume) => { + const socket = new Socket(); + socket.once("close", () => resume(Effect.void)); + socket.once("error", () => undefined); + socket.connect(port, "127.0.0.1"); + return Effect.sync(() => socket.destroy()); + }).pipe(Effect.timeout("5 seconds")); + +it.live("logs one error naming the endpoint when its target fails to wake", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const listener = yield* bindTcp("127.0.0.1", 0); + if (!Predicate.isTagged(listener.address, "TcpAddress")) + return yield* Effect.die("Expected TCP listener"); + const port = listener.address.port; + const target = Effect.fail(new ProxyError({ message: "wake failed" })); + yield* serveTcp(listener, target, "db:sql").pipe(Effect.forkScoped); + yield* connectAndAwaitClose(port); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Endpoint db:sql failed"); + }), + ).pipe(Effect.provide(captureErrors(logs))); +}); + +interface ResetBackend { + readonly port: number; + readonly server: Server; +} + +// Resets only after receiving data, so the reset always lands after a completed handshake. +const listenResetBackend = () => + Effect.acquireRelease( + Effect.callback<ResetBackend, never>((resume) => { + const server = createServer((socket) => { + socket.once("data", () => socket.resetAndDestroy()); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resume( + Effect.succeed({ + port: typeof address === "object" && address !== null ? address.port : 0, + server, + }), + ); + }); + return Effect.void; + }), + ({ server }) => + Effect.callback<void, never>((resume) => { + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); + +it.live("does not log an error when a backend copy resets after a successful connect", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const listener = yield* bindTcp("127.0.0.1", 0); + if (!Predicate.isTagged(listener.address, "TcpAddress")) + return yield* Effect.die("Expected TCP listener"); + const port = listener.address.port; + const backend = yield* listenResetBackend(); + const target = Effect.succeed({ host: "127.0.0.1", port: backend.port }); + yield* serveTcp(listener, target, "db:sql").pipe(Effect.forkScoped); + yield* Effect.callback<void, never>((resume) => { + const socket = new Socket(); + socket.once("close", () => resume(Effect.void)); + socket.once("error", () => undefined); + socket.once("connect", () => socket.write("ping")); + socket.connect(port, "127.0.0.1"); + return Effect.sync(() => socket.destroy()); + }).pipe(Effect.timeout("5 seconds")); + expect(logs).toHaveLength(0); + }), + ).pipe(Effect.provide(captureErrors(logs))); +}); diff --git a/packages/stack/src/Proxy.ts b/packages/stack/src/Proxy.ts index 23006fe87d..9ac7018056 100644 --- a/packages/stack/src/Proxy.ts +++ b/packages/stack/src/Proxy.ts @@ -59,6 +59,7 @@ export const serveTcp = Effect.fn("Proxy.serveTcp")( ( listener: SocketServer.SocketServer["Service"], target: Effect.Effect<BackendAddress, ProxyError, Scope.Scope>, + label: string, ) => listener.run(() => Effect.scoped( @@ -86,8 +87,14 @@ export const serveTcp = Effect.fn("Proxy.serveTcp")( }); }); yield* Effect.gen(function* () { - const address = yield* target; - const backend = yield* connect(address); + const backend = yield* Effect.gen(function* () { + const address = yield* target; + return yield* connect(address); + }).pipe( + Effect.tapError((cause) => + Effect.logError(`Endpoint ${label} failed: ${cause.message}`), + ), + ); yield* Effect.all([copy(incoming, backend), copy(backend, incoming)], { concurrency: "unbounded", discard: true, diff --git a/packages/stack/src/composition/Supabase.unit.test.ts b/packages/stack/src/composition/Supabase.unit.test.ts index e4f692f074..8d0e259c90 100644 --- a/packages/stack/src/composition/Supabase.unit.test.ts +++ b/packages/stack/src/composition/Supabase.unit.test.ts @@ -35,6 +35,7 @@ const makeInstance = ( ); const instance: Orchestrator.RegisteredInstance = { id, + service: id, core, startAt: (revision, inputs, wake, guard) => core.startAt(revision, inputs, wake, guard), restart: (revision, inputs, config, guard) => core.restart(inputs, revision, guard), diff --git a/packages/stack/tests/docker-relay.ts b/packages/stack/tests/docker-relay.ts index 975de1ae79..003922d0a5 100644 --- a/packages/stack/tests/docker-relay.ts +++ b/packages/stack/tests/docker-relay.ts @@ -21,7 +21,7 @@ export const makeDockerTcpRelay = Effect.fn("DockerRelay.makeTcp")( const listener = yield* bindTcp("0.0.0.0", 0); if (!Predicate.isTagged(listener.address, "TcpAddress")) return yield* Effect.die("Expected TCP relay listener"); - yield* serveTcp(listener, address(endpoint)).pipe(Effect.forkScoped); + yield* serveTcp(listener, address(endpoint), "docker-relay").pipe(Effect.forkScoped); return { host: "host.docker.internal", port: listener.address.port }; }), ); diff --git a/packages/stack/tests/logs.ts b/packages/stack/tests/logs.ts new file mode 100644 index 0000000000..a6bc76ea59 --- /dev/null +++ b/packages/stack/tests/logs.ts @@ -0,0 +1,10 @@ +import { Logger, type LogLevel } from "effect"; + +/** Captures rendered log lines at the given levels into `lines` for assertions in tests. */ +export const captureLogs = (levels: ReadonlyArray<LogLevel.LogLevel>) => (lines: Array<string>) => + Logger.layer([ + Logger.make(({ logLevel, message }) => { + if (levels.some((level) => level === logLevel)) + lines.push((Array.isArray(message) ? message : [message]).map(String).join(" ")); + }), + ]); diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index 6c3675f049..98e048cf87 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -7,6 +7,7 @@ import { FileSystem, Layer, Option, + Path, Redacted, Ref, Stream, @@ -86,6 +87,29 @@ const watchServiceLogs = Effect.fn("WholeStack.watchServiceLogs")( const endpoint = (port: "auto") => ({ port }); +/** Bounds diagnostic log text to its last lines, so a runaway owner log stays readable. */ +const tailLines = (content: string, limit: number): string => + content.trimEnd().split("\n").slice(-limit).join("\n"); + +/** Reads only the end of the owner log, so a runaway log doesn't slow down diagnostics. */ +const ownerLogTail = Effect.fn("WholeStack.ownerLogTail")( + (fs: FileSystem.FileSystem, path: Path.Path, stateRoot: string, stackId: string) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(path.join(stateRoot, stackId, "owner.log")); + const size = (yield* file.stat).size; + const length = size < 4096n ? size : 4096n; + yield* file.seek(size - length, "start"); + const bytes = yield* file.readAlloc(length); + const content = Option.match(bytes, { + onNone: () => "", + onSome: (buffer) => new TextDecoder().decode(buffer), + }); + return tailLines(content, 40); + }), + ).pipe(Effect.orElseSucceed(() => "")), +); + export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -227,8 +251,10 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => ), ); const tails = yield* Ref.get(logTails); + const path = yield* Path.Path; + const ownerLog = yield* ownerLogTail(fs, path, locations.stateRoot, stack.id); yield* Effect.logError( - `Whole-stack failure diagnostics: cause=${Cause.pretty(exit.cause)} statuses=${statuses.join(",")} logs=${tails.map(([name, value]) => `${name}: ${value}`).join("\n")}`, + `Whole-stack failure diagnostics: cause=${Cause.pretty(exit.cause)} statuses=${statuses.join(",")} logs=${tails.map(([name, value]) => `${name}: ${value}`).join("\n")} owner log=${ownerLog}`, ); }).pipe(Effect.ignoreCause) : Effect.void, From 37043833af5d913ee233d76cd9fa63760c43063c Mon Sep 17 00:00:00 2001 From: Andrew Valleteau <avallete@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:45:44 +0000 Subject: [PATCH 66/71] fix(cli): skip the contrib_regression clone on pg-delta shadows (#6920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR pg-delta shadow databases no longer run `CREATE DATABASE contrib_regression TEMPLATE postgres`. That clone crashes OrioleDB when the platform baseline contains an enum-indexed OrioleDB table, and the crash takes down the whole shadow. Migra still creates the database, because it uses it as the declarative diff target. ## Before ```mermaid flowchart LR shadow["pg-delta shadow"] --> clone["CREATE DATABASE TEMPLATE"] clone --> crash["OrioleDB crash"] ``` ## After ```mermaid flowchart LR next["pg-delta shadow"] --> pg["postgres only"] migra["migra shadow"] --> clone["contrib_regression"] ``` ## Why On an OrioleDB image, `CREATE DATABASE … TEMPLATE` segfaults when the template has an OrioleDB index on an enum column. A fresh Supabase database has one: `auth.one_time_tokens_user_id_token_type_key`. Shadow setup then fails with a connection error. pg-delta never connects to `contrib_regression`. ## What changed - The pg-delta declarative shadow applies the platform baseline and does not clone `contrib_regression`. A warm cache hit does not connect just to create it. - The pg-delta migrations shadow applies migrations on `postgres` and skips the clone. - The migra shadow still creates `contrib_regression`. --- .../db-bootstrap/shadow-database.ts | 81 ++++++------- .../db-bootstrap/shadow-database.unit.test.ts | 114 +++++++++--------- .../src/commands/db/shared/shadow-source.ts | 7 +- 3 files changed, 100 insertions(+), 102 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index 717fbde5c4..8ad2daee30 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -279,10 +279,9 @@ export interface ShadowSourceResult { /** The diff source Postgres URL (the provisioned shadow). */ readonly sourceUrl: string; /** - * When set, replaces the diff target with a second database on the same shadow container - * (`contrib_regression`, cloned from `postgres` during shadow setup — see - * {@link setupShadowConn}) with declarative schemas applied, so the user's local DB is never - * diffed directly in that branch. + * Legacy migra only. When set, replaces the diff target with `contrib_regression` on the + * same shadow container (cloned from `postgres` by {@link setupShadowConn}) after declarative + * schemas are applied. pg-delta leaves this unset and diffs `postgres`. */ readonly targetUrlOverride: string | undefined; } @@ -462,10 +461,9 @@ export const setupShadowConn = ( }); /** - * {@link setupShadowConn}'s trailing {@link SHADOW_CREATE_TEMPLATE_SQL} step on its own. Split - * out because it's the one part a warm shadow-cache hit still has to run: the cache's PGDATA - * snapshot is taken before this statement, so a restored cluster carries the platform baseline - * but no `contrib_regression`. + * Clones `contrib_regression` for the legacy migra shadow. The cache snapshot is taken + * before this statement, so a warm hit still runs it when that caller asks for the clone. + * pg-delta does not call this. */ const createShadowTemplateDatabase = ( session: DbSession, @@ -547,14 +545,13 @@ export const buildShadowSetupDatabaseInput = <E>( }); /** - * Connects to the shadow, then resolves the setup prelude (JWKS/pinned image names) and runs - * {@link setupShadowConn} — the platform baseline plus the template database, no user - * migrations. Connect-then-setup so an unconnectable shadow surfaces a connect error immediately - * rather than paying for JWKS work first. The connection closes once this resolves. + * Connects to the shadow, resolves the setup prelude, and applies the platform baseline. + * No user migrations. Does not create `contrib_regression`: pg-delta diffs this database, + * and `CREATE DATABASE … TEMPLATE` crashes OrioleDB when the baseline has an enum-indexed + * OrioleDB table. * - * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit skips the prelude and - * setup (the restored cluster already has them) and only recreates `contrib_regression`; a - * cache-enabled cold provision snapshots between the baseline and the template. + * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit returns immediately. + * A cache-enabled cold provision snapshots after the baseline and does not reconnect. */ export const setupShadowDatabase = <E>( spawner: Spawner, @@ -568,7 +565,8 @@ export const setupShadowDatabase = <E>( > => Effect.scoped( Effect.gen(function* () { - if (!baseline.baselinePresent && baseline.snapshotRequired) { + if (baseline.baselinePresent) return; + if (baseline.snapshotRequired) { yield* Effect.scoped( Effect.gen(function* () { const setupSession = yield* connectShadowDatabase(input.connConfig); @@ -587,22 +585,20 @@ export const setupShadowDatabase = <E>( }), ); yield* baseline.snapshotBaseline; + return; } const session = yield* connectShadowDatabase(input.connConfig); - if (!baseline.baselinePresent && !baseline.snapshotRequired) { - const resolved = yield* resolveDbSetupPrelude(input.setup); - yield* setupDatabase( - spawner, - buildShadowSetupDatabaseInput(input, session, resolved), - options, - ).pipe( - // Same connect-failure classification as the snapshot branch above. - Effect.catchTag("DbConnectError", (cause) => - Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), - ), - ); - } - yield* createShadowTemplateDatabase(session); + const resolved = yield* resolveDbSetupPrelude(input.setup); + yield* setupDatabase( + spawner, + buildShadowSetupDatabaseInput(input, session, resolved), + options, + ).pipe( + // Same connect-failure classification as the snapshot branch above. + Effect.catchTag("DbConnectError", (cause) => + Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); }), ); @@ -646,12 +642,12 @@ const SHADOW_BASELINE_COLD: ShadowBaselineState = { /** * Lists local migrations first, so a bad migrations directory fails before any DB connection, - * then connects, resolves the setup prelude, and runs {@link setupShadowConn} (platform baseline - * plus template database) before applying every listed migration. Connect-then-setup for the - * same reason as {@link setupShadowDatabase}. + * then connects, resolves the setup prelude, and runs the platform baseline before applying + * every listed migration. `options.createTemplateDatabase` clones `contrib_regression` for the + * legacy engine only. Connect-then-setup for the same reason as {@link setupShadowDatabase}. * * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm hit skips the prelude and setup; a - * cold cache-enabled provision snapshots between the baseline and the template. Only that + * cold cache-enabled provision snapshots between the baseline and later steps. Only that * snapshotting branch splits sessions — see {@link ShadowBaselineState.snapshotRequired}. */ const migrateShadowDatabaseWith = <E>( @@ -659,6 +655,7 @@ const migrateShadowDatabaseWith = <E>( input: ShadowSetupRunInput<E>, setupOptions: SetupDatabaseOptions, baseline: ShadowBaselineState = SHADOW_BASELINE_COLD, + options: { readonly createTemplateDatabase?: boolean } = {}, ): Effect.Effect< void, StartSetupLocalDatabaseError | ShadowDbError | ImagePrepullError | E, @@ -697,8 +694,8 @@ const migrateShadowDatabaseWith = <E>( } const session = yield* connectShadowDatabase(input.connConfig); if (!baseline.baselinePresent && !baseline.snapshotRequired) { - // The established single-session flow: baseline + template + migrations all on this - // one session — see this function's own doc comment. + // The established single-session flow: baseline and migrations share this session. + // A reconnect would pick up role-level defaults `roles.sql` just installed. const resolved = yield* resolveDbSetupPrelude(input.setup); yield* setupDatabase( spawner, @@ -711,7 +708,9 @@ const migrateShadowDatabaseWith = <E>( ), ); } - yield* createShadowTemplateDatabase(session); + if (options.createTemplateDatabase !== false) { + yield* createShadowTemplateDatabase(session); + } yield* applyMigrations( session, input.fs, @@ -744,9 +743,9 @@ export const migrateShadowDatabase = <E>( > => migrateShadowDatabaseWith(spawner, input, { webhooks: "enabled" }, baseline); /** - * Migrates a shadow for the in-process pg-delta engine. Unlike the legacy engine, - * extension activation follows project config through `setupDatabase`'s - * default options. + * Migrates a shadow for the in-process pg-delta engine. Extension activation follows + * project config. Does not create `contrib_regression`: pg-delta never connects to it, + * and the clone crashes OrioleDB when the baseline has an enum-indexed OrioleDB table. */ export const migrateNextShadowDatabase = <E>( spawner: Spawner, @@ -756,4 +755,4 @@ export const migrateNextShadowDatabase = <E>( void, StartSetupLocalDatabaseError | ShadowDbError | ImagePrepullError | E, Output | DockerRun | RuntimeInfo | DbConnection -> => migrateShadowDatabaseWith(spawner, input, {}, baseline); +> => migrateShadowDatabaseWith(spawner, input, {}, baseline, { createTemplateDatabase: false }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts index 2b0fe943e7..4bf1d5ceac 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts @@ -504,7 +504,7 @@ describe("buildShadowSetupDatabaseInput", () => { describe("setupShadowDatabase / migrateShadowDatabase", () => { it.effect( - "setupShadowDatabase connects, sets up the platform baseline, and creates the template database", + "setupShadowDatabase applies the platform baseline without creating contrib_regression", () => { const { session, calls } = fakeSession(); const workdir = tempRoot.current; @@ -528,7 +528,8 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { }, setup: baseShadowSetup(), }); - expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + expect(mock.spawned.length).toBeGreaterThan(0); + expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); }).pipe( Effect.provide( Layer.mergeAll( @@ -596,6 +597,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { Effect.tap(() => Effect.sync(() => { expect(calls.some((call) => call.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false); + expect(calls.some((call) => call.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); }), ), ); @@ -654,6 +656,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { ); expect(jwksEvaluated).toBe(false); expect(calls.some((call) => call.sql === "drop extension if exists pg_net")).toBe(true); + expect(calls.some((call) => call.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); }).pipe( Effect.provide( Layer.mergeAll( @@ -713,63 +716,60 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { ); }); - it.effect( - "skips the platform baseline on a warm cache hit but still creates the template", - () => { - const { session, calls } = fakeSession(); - const workdir = tempRoot.current; - const mock = mockSpawner(); - let jwksEvaluated = false; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* setupShadowDatabase( - mock.spawner, - { - fs, - path, - workdir, - projectId: "proj", - container: "shadow-container-id-0123456789abcdef", - networkId: "supabase_network_proj", - connConfig: { - host: "127.0.0.1", - port: 54320, - user: "postgres", - password: "postgres", - database: "postgres", - }, - setup: baseShadowSetup({ - majorVersion: 17, - realtimeEnabledForSetup: true, - jwks: Effect.sync(() => { - jwksEvaluated = true; - return '{"keys":[]}'; - }), - }), - }, - {}, - { - baselinePresent: true, - snapshotRequired: false, - snapshotBaseline: Effect.void, + it.effect("skips the platform baseline and contrib_regression on a warm cache hit", () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + let jwksEvaluated = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* setupShadowDatabase( + mock.spawner, + { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", }, - ); - expect(jwksEvaluated).toBe(false); - expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - mockOutput().layer, - mockDockerRun(), - mockRuntimeInfo(), - mockDbConnection(session), - ), - ), + setup: baseShadowSetup({ + majorVersion: 17, + realtimeEnabledForSetup: true, + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }, + {}, + { + baselinePresent: true, + snapshotRequired: false, + snapshotBaseline: Effect.void, + }, ); - }, - ); + expect(jwksEvaluated).toBe(false); + expect(calls).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }); it.effect( "migrateShadowDatabase lists local migrations BEFORE connecting, tolerating a missing migrations directory as an empty list rather than a failure", diff --git a/apps/cli/src/commands/db/shared/shadow-source.ts b/apps/cli/src/commands/db/shared/shadow-source.ts index f840e43a9c..7dcf6df614 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.ts @@ -97,10 +97,9 @@ export const prepareShadowSource = <E>( image: input.image, }); - // `handle` doubles as the baseline state: on a warm shadow-cache hit it already holds the - // platform baseline (so only the template database + user migrations run); on a - // cache-enabled cold provision it carries the snapshot step that runs between the two — see - // `shadow-cache.ts`/`ShadowBaselineState`. An uncached acquire is always-cold. + // `handle` is the baseline state. A warm hit already has the platform baseline. A + // cache-enabled cold provision snapshots after that baseline. The legacy engine then + // creates `contrib_regression`; pg-delta does not. An uncached acquire is always-cold. const migrateShadow = input.migrationMode === "pgdelta-next" ? migrateNextShadowDatabase : migrateShadowDatabase; yield* migrateShadow( From a0711c0c0872c4de7404eb47f0fd30175be7c67d Mon Sep 17 00:00:00 2001 From: Andrew Valleteau <avallete@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:38:21 +0000 Subject: [PATCH 67/71] fix(stack): keep Storage uploads working on Docker Desktop for macOS (#6921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary On Docker Desktop for macOS, every upload to a stack's Storage on the Docker runtime failed with 500 `ENOTSUP`. Storage's file backend keeps object metadata in extended attributes, and Docker Desktop's file sharing drops them on bind mounts. This pins the stack's Storage to slim `v1.79.28-r1`, whose `fs-xattr` wrapper falls back to sidecar metadata files when xattrs are rejected. The bind mount at `supabase/.temp/stack-uploads/<stack-id>` stays as it is. This replaces the engine-volume workaround in #6908. ### Before ```mermaid flowchart LR A["Upload to stack Storage<br/>(Docker Desktop macOS)"] --> B["storage v1.73.0<br/>fs-xattr setAttributeSync"] B -->|ENOTSUP on bind mount| C["500 on every upload"] ``` ### After ```mermaid flowchart LR A["Upload to stack Storage<br/>(Docker Desktop macOS)"] --> B["storage v1.79.28-r1<br/>fs-xattr wrapper"] B -->|xattrs work| C["native attributes"] B -->|ENOTSUP| D["sidecar under<br/>stack-uploads/.slim-xattr"] ``` ### What changed - **Storage pin:** `packages/stack/src/Artifacts.ts` moves from `v1.73.0` to `v1.79.28-r1`, pinned by digest. This is the first catalog pin to a revisioned slim release, and the release tag, asset names, native OCI tags and image tag all follow the `v1.79.28-r1` form the catalog already derives. - **Fix location:** the fix lives in the slim build, in supabase/slim-services#329 and supabase/slim-services#330. - Native xattrs stay authoritative wherever they work: Linux, OrbStack and Windows Docker Desktop. - On `ENOTSUP`, metadata goes to `.slim-xattr/<2 hex>/<sha256>.json` under the uploads directory. - A throttled background sweep removes sidecars for deleted objects without blocking requests. - **`supabase services`:** in stack mode it reports catalog versions without the slim revision suffix. A linked project on the same upstream release, for example `v1.79.28`, therefore doesn't show a version mismatch. `SIDE_EFFECTS.md` is updated to match. ### Reviewer notes - The Storage version jump from `v1.73.0` to `v1.79.28` was compared on Docker Desktop against the pinned image: status codes and headers matched across a broad set of Storage API scenarios, both on native volumes and through the sidecar. - Known limitations of the fallback are recorded in slim-services `services/storage/REPORT.md`. - An object uploaded where xattrs work, then read under Docker Desktop, is served as `application/octet-stream`. - A multipart part rewritten across two engine switches within one upload keeps a stale etag. - The legacy `supabase start` Dockerfile pins (`supabase/storage-api:v1.77.0`) are unchanged. It mounts Storage from a named volume, so it isn't affected. ## Linked issue Closes #6900 - [x] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/services/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/services/services-local-stack.ts | 3 ++- apps/cli/src/commands/services/services.integration.test.ts | 3 +++ packages/stack/src/Artifacts.ts | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/services/SIDE_EFFECTS.md b/apps/cli/src/commands/services/SIDE_EFFECTS.md index 646d88e098..63b4d68c7b 100644 --- a/apps/cli/src/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/services/SIDE_EFFECTS.md @@ -86,7 +86,7 @@ TS-only NDJSON success event with the same `{ services: [...] }` payload. ## Notes - Backend selection follows canonical experimental-feature routing: `SUPABASE_EXPERIMENTAL_STACK=1|0` takes precedence over `experimental.stack`; unset or empty uses project config, and an invalid environment value fails. Output fields and serializers stay the same. -- The legacy backend uses its baked-in service matrix and honors its existing config/version overrides. The stack backend lists services from the installed CLI artifact catalog using canonical `ghcr.io/supabase/cli/...` image names and catalog versions. Native and Docker runtimes use the same catalog versions. Since the catalog belongs to the installed CLI, an older launched CLI or a newer/mirrored stack image may differ from this inventory. The command does not inspect running containers, image pulls, service health, or live stack state. +- The legacy backend uses its baked-in service matrix and honors its existing config/version overrides. The stack backend lists services from the installed CLI artifact catalog using canonical `ghcr.io/supabase/cli/...` image names and catalog versions, reported as the upstream version without a slim revision suffix (`-rN`). Native and Docker runtimes use the same catalog versions. Since the catalog belongs to the installed CLI, an older launched CLI or a newer/mirrored stack image may differ from this inventory. The command does not inspect running containers, image pulls, service health, or live stack state. - For stack mode, PostgreSQL uses the configured major version or `SUPABASE_DB_MAJOR_VERSION` (15 or 17). Invalid configuration or an unsupported PostgreSQL major warns with the cause and falls back to default catalog versions; absent config uses defaults. Legacy image pins, slim-image rewriting, and remote image overrides do not affect stack results. - Linked-version checks are best-effort. Remote lookup failures do not change the exit code; they only leave the `LINKED` column empty for unavailable services. - A malformed linked ref is the one lookup failure that prints an explicit stderr warning (see API Routes above); every other remote failure (network error, expired token, etc.) still fails silently and just leaves `LINKED` empty. Most real-world malformed refs come from an untrimmed `SUPABASE_PROJECT_ID` env var (e.g. a trailing newline from a secrets manager or `.env` file) rather than actual file tampering — the env var is read raw and unlike the on-disk `project-ref` file is never trimmed. diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts index aa3017cb9d..2bfa8f6d78 100644 --- a/apps/cli/src/commands/services/services-local-stack.ts +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -52,7 +52,8 @@ export const stackServiceVersions = Effect.fn("services.stackServiceVersions")(f const remoteName = remoteNames[service]; return { name, - local: artifact.version, + // A slim revision suffix (`-rN`) repackages the same upstream release. + local: artifact.version.replace(/-r\d+$/u, ""), remote: remoteName === undefined ? "" : (remote[remoteName] ?? ""), } satisfies ServiceVersionRow; }), diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 10970a06e0..b468afc5e6 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -342,6 +342,9 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "ghcr.io/supabase/cli/vector", local: "0.53.0" }), ); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/storage", local: "v1.79.28" }), + ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 1637340042..0cd6c8815a 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -91,8 +91,8 @@ const definitions: Readonly<Record<ServiceKind, ArtifactDefinition>> = { ), storage: definition( "storage", - "v1.73.0", - "ghcr.io/supabase/cli/storage:v1.73.0@sha256:69590a75f916837641976d4018e5ead7c7d2c2305312d9bfb06d86aec8fb1cdd", + "v1.79.28-r1", + "ghcr.io/supabase/cli/storage:v1.79.28-r1@sha256:95e0007f273e7c990ab81c44e021e4278b8953dd95ae2fbdc19fca047d4bd470", "bin/storage", ["bin/storage", "bin/prepare"], ), From 481cb3f43650c093be29147b32e685e5b4b0bf77 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau <avallete@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:21:30 +0000 Subject: [PATCH 68/71] fix(stack): serve Storage S3 and resumable uploads behind the gateway (#6917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary With the experimental stack, Storage behind the gateway at `<API_URL>/storage/v1` was missing configuration that the legacy `supabase start` path sets. This broke S3 clients, resumable (TUS) uploads, and therefore Studio file uploads, on both the native and Docker runtimes. **Storage recipe (`packages/stack/src/services/Storage.ts`)** - `S3_PROTOCOL_PREFIX=/storage/v1`: the gateway strips the prefix and sends no `x-forwarded-prefix`, so Storage verified SigV4 signatures against a different canonical path (`SignatureDoesNotMatch`). - `S3_PROTOCOL_ACCESS_KEY_ID`, `S3_PROTOCOL_ACCESS_KEY_SECRET`, `STORAGE_S3_REGION`: new optional `s3AccessKeyId`, `s3SecretAccessKey`, `s3Region` config, defaulting to the package's local S3 defaults. Access-key S3 auth was previously unavailable. - `TUS_URL_PATH=/storage/v1/upload/resumable`: the TUS `Location` header dropped `/storage/v1`. - `NODE_ENV=development`: the Storage image sets `NODE_ENV=production`, and storage-api then forces `https` into TUS upload URLs. Only the Docker runtime was affected, because native processes don't inherit the host's `NODE_ENV`. - Legacy parity: `UPLOAD_FILE_SIZE_LIMIT_STANDARD` (5 GB) and `SIGNED_UPLOAD_URL_EXPIRATION_TIME` (7200s, instead of storage-api's 60s default). Image transformation now uses the preferred `IMAGE_TRANSFORMATION_ENABLED` key. - The `/storage/v1` prefix is now one exported constant, also used by the gateway route in `host/Endpoints.ts`, so the route and the Storage config can't drift. **CLI** - `stack-config.ts` passes the local S3 keys and region into the Storage creation. - Stack `status` shows the Storage S3 URL, access keys, and region when the Storage member enables the S3 protocol. `--env` emits `STORAGE_S3_URL`, `S3_PROTOCOL_ACCESS_KEY_ID`, `S3_PROTOCOL_ACCESS_KEY_SECRET`, and `S3_PROTOCOL_REGION`, matching legacy `status` names. The previously reserved but never-emitted `S3_PROTOCOL_URL` name is replaced by `STORAGE_S3_URL`. The same variables appear in the `env` map of `stack start -o json`. Status omits the S3 details for a Storage member saved without S3 keys, such as one started before this change. **Notes for reviewers** - `UPLOAD_FILE_SIZE_LIMIT` is intentionally not copied from legacy. Legacy sets it to 50 GB, which silently overrides the configured `storage.file_size_limit`. The stack keeps enforcing the configured limit through `FILE_SIZE_LIMIT`. - Existing stacks will report config drift for the new Storage keys and need a stop/start to pick them up. - The new `StorageGateway.integration.test.ts` runs Database and Storage behind the real gateway on native and Docker. It exercises Bun's S3 client over `/storage/v1/s3`, the TUS `Location` header, and a PATCH to it. On macOS Docker Desktop, its TUS step fails because of #6900 (bind mounts without xattr support), like the existing Docker storage upload test. Linux is unaffected. ## Linked issue Closes #6916 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: avallete <andrew@snaplet.dev> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/command-internal/stack-config.ts | 10 +- .../experimental/stack/stack-summary.ts | 44 ++++ .../experimental/stack/start/SIDE_EFFECTS.md | 7 +- .../stack/start/start.storage.e2e.test.ts | 209 ++++++++++++++++++ .../experimental/stack/status/SIDE_EFFECTS.md | 17 +- .../experimental/stack/status/status.env.ts | 11 +- .../stack/status/status.env.unit.test.ts | 14 +- .../stack/status/status.integration.test.ts | 94 ++++++++ packages/stack/src/host/Endpoints.ts | 3 +- packages/stack/src/services/Storage.ts | 23 +- .../StorageGateway.integration.test.ts | 153 +++++++++++++ 11 files changed, 560 insertions(+), 25 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts create mode 100644 packages/stack/src/services/StorageGateway.integration.test.ts diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index cb2c35adb3..c9a41e75f1 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -1,7 +1,12 @@ import { getDefaultCliConfig, type CliConfig } from "@supabase/config"; import { resolveCliConfigSubtree } from "@supabase/config/internal"; import { validateCliConfig } from "@supabase/config/effect"; -import { DEFAULT_SIGNING_KEY } from "@supabase/stack/defaults"; +import { + DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + DEFAULT_LOCAL_S3_REGION, + DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + DEFAULT_SIGNING_KEY, +} from "@supabase/stack/defaults"; import { type ServiceCreationInput as ServiceCreationType } from "@supabase/stack/effect"; import { Crypto, Effect, Data, FileSystem, Path, Redacted, Schema, SchemaIssue } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; @@ -1234,6 +1239,9 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( filePath: `${storagePath}/${stackId}`, fileSizeLimit: storageFileSizeLimit, s3ProtocolEnabled: validatedConfig.storage.s3_protocol.enabled, + s3AccessKeyId: DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + s3SecretAccessKey: DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + s3Region: DEFAULT_LOCAL_S3_REGION, vectorEnabled: validatedConfig.storage.vector.enabled, vectorMaxBuckets: validatedConfig.storage.vector.max_buckets, vectorMaxIndexes: validatedConfig.storage.vector.max_indexes, diff --git a/apps/cli/src/commands/experimental/stack/stack-summary.ts b/apps/cli/src/commands/experimental/stack/stack-summary.ts index 04931ff5c2..3354c75b6a 100644 --- a/apps/cli/src/commands/experimental/stack/stack-summary.ts +++ b/apps/cli/src/commands/experimental/stack/stack-summary.ts @@ -35,6 +35,14 @@ export interface StackServiceView { readonly error?: string | undefined; } +/** S3 protocol endpoint and local access keys of the Storage member. */ +interface StackStorageS3 { + readonly url: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; + readonly region: string; +} + /** Connection URLs derived from the observed composition members. */ export interface StackConnections { readonly api?: string; @@ -44,6 +52,7 @@ export interface StackConnections { readonly mcp?: string; readonly mailpit?: string; readonly database?: string; + readonly s3?: StackStorageS3; } export const serviceState = (observation: Observation | undefined): StackServiceState => { @@ -79,6 +88,26 @@ const stackDatabaseUrl = (observation: Observation | undefined): string | undefi }); }; +const stackStorageS3 = ( + storageUrl: string | undefined, + observation: Observation | undefined, +): StackStorageS3 | undefined => { + if (storageUrl === undefined || observation?.config.service !== "storage") return undefined; + const { s3ProtocolEnabled, s3AccessKeyId, s3SecretAccessKey, s3Region } = + observation.config.config; + return s3ProtocolEnabled === false || + s3AccessKeyId === undefined || + s3SecretAccessKey === undefined || + s3Region === undefined + ? undefined + : { + url: `${storageUrl}/s3`, + accessKeyId: s3AccessKeyId, + secretAccessKey: s3SecretAccessKey, + region: s3Region, + }; +}; + /** Derives connection URLs; callers pass composition members only. */ export const stackConnections = ( members: ReadonlyArray<Pick<StackServiceView, "service" | "observation">>, @@ -104,6 +133,10 @@ export const stackConnections = ( const database = stackDatabaseUrl( members.find(({ service }) => service === "database")?.observation, ); + const s3 = stackStorageS3( + routed("storage"), + members.find(({ service }) => service === "storage")?.observation, + ); return { ...(api === undefined ? {} : { api }), ...(rest === undefined ? {} : { rest }), @@ -114,6 +147,7 @@ export const stackConnections = ( ...(api !== undefined && studio !== undefined ? { mcp: `${api}/mcp` } : {}), ...(mailpit === undefined ? {} : { mailpit }), ...(database === undefined ? {} : { database }), + ...(s3 === undefined ? {} : { s3 }), }; }; @@ -142,6 +176,12 @@ export const connectionEnv = ( values.ANON_KEY = credentials.anonKey; values.SERVICE_ROLE_KEY = credentials.serviceRoleKey; } + if (connections.s3 !== undefined) { + values.STORAGE_S3_URL = connections.s3.url; + values.S3_PROTOCOL_ACCESS_KEY_ID = connections.s3.accessKeyId; + values.S3_PROTOCOL_ACCESS_KEY_SECRET = connections.s3.secretAccessKey; + values.S3_PROTOCOL_REGION = connections.s3.region; + } return values; }; @@ -160,6 +200,10 @@ const connectionValues = ( [names.dbUrl, connections.database], [names.publishableKey, credentials?.publishableKey], [names.secretKey, credentials?.secretKey], + [names.storageS3Url, connections.s3?.url], + [names.storageS3AccessKeyId, connections.s3?.accessKeyId], + [names.storageS3SecretAccessKey, connections.s3?.secretAccessKey], + [names.storageS3Region, connections.s3?.region], ]; return { names, diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 36d633c0f2..59b0bf2b38 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -20,7 +20,9 @@ If the stack is otherwise in a partial lifecycle state, start fails with guidanc and start it again before applying configuration. Auth policies, OAuth providers, hooks, MFA, SMTP, email subjects and notification controls are forwarded to Auth. REST search paths, pooler limits, Realtime settings, Studio settings, Storage -S3 protocol/vector controls, and configured Vector ports are forwarded to their services. +S3 protocol/vector controls, and configured Vector ports are forwarded to their services. Storage +receives the local S3 access keys and region, and uses the gateway's `/storage/v1` prefix to verify +S3 signatures and to build resumable upload URLs. Encrypted JWT secrets are decrypted before shared credentials are derived. `db.health_timeout` controls database readiness; package JWT and PostgreSQL root-key defaults apply when omitted, and the effective root key is supplied through a stack-owned key file. @@ -123,7 +125,8 @@ Storage HTTP endpoint. The CLI does not remove caller-owned Storage files during Text output reports progress and `Stack is ready.`, then prints the connection summary shared with `stack status` on stdout: API, REST, Functions, Studio, MCP, Mailpit, and database URLs for the -members that expose them, the publishable and secret keys, a services table, the runtime, and a +members that expose them, the publishable and secret keys, the Storage S3 URL, access keys, and +region when the S3 protocol is enabled, a services table, the runtime, and a pointer to `supabase status --env` that repeats an explicit `--workdir` and any `--stack` or `--stack-id` selector, shell-quoted. Progress lines and warnings written while the spinner is shown appear on their own rows. diff --git a/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts new file mode 100644 index 0000000000..c268717965 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts @@ -0,0 +1,209 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { tmpdir } from "node:os"; + +import { + makeTempCliProject, + makeTempHome, + runSupabaseEffect, +} from "../../../../../tests/helpers/cli.ts"; + +const COMMAND_TIMEOUT_MS = 10 * 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const nativeSupported = + (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || + (process.platform === "darwin" && process.arch === "arm64"); + +const projectConfig = `project_id = "stack-storage-e2e" + +[experimental] +stack = true + +[api] +enabled = true + +[storage] +enabled = true + +[storage.image_transformation] +enabled = false + +[auth] +enabled = false + +[db.pooler] +enabled = false + +[edge_runtime] +enabled = false + +[realtime] +enabled = false + +[studio] +enabled = false + +[analytics] +enabled = false + +[local_smtp] +enabled = false +`; + +class StackStorageE2eError extends Data.TaggedError("StackStorageE2eError")<{ + readonly message: string; +}> {} + +const StartResultSchema = Schema.Struct({ id: Schema.String }); +const StorageEnvSchema = Schema.Struct({ + API_URL: Schema.String, + SERVICE_ROLE_KEY: Schema.String, + STORAGE_S3_URL: Schema.String, + S3_PROTOCOL_ACCESS_KEY_ID: Schema.String, + S3_PROTOCOL_ACCESS_KEY_SECRET: Schema.String, + S3_PROTOCOL_REGION: Schema.String, +}); + +const s3Call = <A>(operation: string, call: () => Promise<A>) => + Effect.tryPromise({ + try: call, + catch: (cause) => + new StackStorageE2eError({ message: `S3 ${operation} failed: ${String(cause)}` }), + }); + +const tusMetadata = (entries: Readonly<Record<string, string>>) => + Object.entries(entries) + .map(([name, value]) => `${name} ${btoa(value)}`) + .join(","); + +describe("stack start Storage behind the API gateway (compiled e2e)", () => { + for (const runtime of ["native", "docker"] as const) { + if (runtime === "native" && !nativeSupported) continue; + it.live( + `serves S3 clients and resumable uploads from the ${runtime} status URLs`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const http = yield* HttpClient.HttpClient; + const home = makeTempHome(); + const project = yield* Effect.promise(() => + makeTempCliProject(`stack-storage-${runtime}-e2e-`), + ); + const artifacts = path.join(tmpdir(), "supabase-stack-artifacts"); + yield* fs.makeDirectory(path.join(home.dir, "cache"), { recursive: true }); + yield* fs.makeDirectory(artifacts, { recursive: true }); + yield* fs.symlink(artifacts, path.join(home.dir, "cache", "stack")); + yield* fs.makeDirectory(path.join(project.dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(project.dir, "supabase", "config.toml"), + projectConfig, + ); + const cli = (args: Array<string>, exitTimeoutMs = CLEANUP_TIMEOUT_MS) => + runSupabaseEffect(args, { + cwd: project.dir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs, + }); + + yield* Effect.addFinalizer((exit) => + cli(["stack", "destroy", "--yes"]).pipe( + Effect.flatMap((destroyed) => + Exit.isFailure(exit) || destroyed.exitCode === 0 + ? Effect.void + : new StackStorageE2eError({ + message: `stack destroy exited ${destroyed.exitCode}: ${destroyed.stderr}`, + }), + ), + Effect.orDie, + ), + ); + + const started = yield* cli( + ["stack", "start", "--runtime", runtime, "--eager", "--output-format", "json"], + COMMAND_TIMEOUT_MS, + ); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe( + 0, + ); + const { id } = yield* Schema.decodeEffect(Schema.fromJsonString(StartResultSchema))( + started.stdout.trim(), + ); + const status = yield* cli([ + "stack", + "status", + "--env", + "--stack-id", + id, + "--output-format", + "json", + ]); + expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + const env = yield* Schema.decodeEffect(Schema.fromJsonString(StorageEnvSchema))( + status.stdout, + ); + const storageUrl = `${env.API_URL}/storage/v1`; + expect(env.STORAGE_S3_URL).toBe(`${storageUrl}/s3`); + const authorization = `Bearer ${env.SERVICE_ROLE_KEY}`; + const bucket = "e2e"; + + const createBucket = yield* http.execute( + HttpClientRequest.post(`${storageUrl}/bucket`).pipe( + HttpClientRequest.setHeaders({ authorization }), + HttpClientRequest.bodyJsonUnsafe({ name: bucket }), + ), + ); + expect(createBucket.status, yield* createBucket.text).toBe(200); + + const s3 = new Bun.S3Client({ + accessKeyId: env.S3_PROTOCOL_ACCESS_KEY_ID, + secretAccessKey: env.S3_PROTOCOL_ACCESS_KEY_SECRET, + region: env.S3_PROTOCOL_REGION, + endpoint: env.STORAGE_S3_URL, + bucket, + }); + yield* s3Call("write", () => s3.write("s3.txt", "written through S3")); + const listing = yield* s3Call("list", () => s3.list()); + expect(listing.contents?.map(({ key }) => key)).toEqual(["s3.txt"]); + expect(yield* s3Call("read", () => s3.file("s3.txt").text())).toBe("written through S3"); + + const body = new TextEncoder().encode("resumable upload"); + const create = yield* http.execute( + HttpClientRequest.post(`${storageUrl}/upload/resumable`).pipe( + HttpClientRequest.setHeaders({ + authorization, + "tus-resumable": "1.0.0", + "upload-length": String(body.length), + "upload-metadata": tusMetadata({ + bucketName: bucket, + objectName: "resumable.txt", + contentType: "text/plain", + }), + }), + ), + ); + expect(create.status, yield* create.text).toBe(201); + const location = create.headers.location ?? ""; + expect(location.startsWith(`${storageUrl}/upload/resumable/`), location).toBe(true); + const patch = yield* http.execute( + HttpClientRequest.patch(location).pipe( + HttpClientRequest.setHeaders({ + authorization, + "tus-resumable": "1.0.0", + "upload-offset": "0", + }), + HttpClientRequest.bodyUint8Array(body, "application/offset+octet-stream"), + ), + ); + expect(patch.status, yield* patch.text).toBe(204); + expect(yield* s3Call("read", () => s3.file("resumable.txt").text())).toBe( + "resumable upload", + ); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, FetchHttpClient.layer])), + { timeout: COMMAND_TIMEOUT_MS + 2 * CLEANUP_TIMEOUT_MS }, + ); + } +}); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index 3868370a73..dd2200f2c3 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -37,7 +37,8 @@ Text output starts with one line naming the stack, its readiness, runtime, and project directory, noting when the owner is unavailable. It then prints the connection summary shared with `stack start`: the API, REST, Functions, Studio, MCP, Mailpit, and database URLs that the composition members expose, the -saved publishable and secret keys, and a services table with each service's +saved publishable and secret keys, the Storage S3 URL, access keys, and region +when the Storage member enables the S3 protocol, and a services table with each service's state, health, and activation; sleeping lazy services are marked as starting on first request. Service errors follow the tables, and config drift ends the output as one muted line unless the configuration drifted. Stack and service IDs appear only @@ -66,11 +67,15 @@ or compare project configuration. Text output emits dotenv assignments; JSON and stream-JSON output emit a plain variable map under a successful result. Unavailable optional credentials and endpoints are omitted. The exported variable set is `API_URL`, `REST_URL`, `FUNCTIONS_URL`, `DB_URL`, `STUDIO_URL`, -`MCP_URL`, `MAILPIT_URL`, `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, and -`SERVICE_ROLE_KEY`; `REST_URL` and `FUNCTIONS_URL` are `<API_URL>/rest/v1` and -`<API_URL>/functions/v1`, present only when their member is a composition -member with an HTTP endpoint. `ANON_KEY` and `SERVICE_ROLE_KEY` are emitted -from the required saved database JWT secret. +`MCP_URL`, `MAILPIT_URL`, `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, +`SERVICE_ROLE_KEY`, `STORAGE_S3_URL`, `S3_PROTOCOL_ACCESS_KEY_ID`, +`S3_PROTOCOL_ACCESS_KEY_SECRET`, and `S3_PROTOCOL_REGION`; `REST_URL` and +`FUNCTIONS_URL` are `<API_URL>/rest/v1` and `<API_URL>/functions/v1`, present +only when their member is a composition member with an HTTP endpoint. +`STORAGE_S3_URL` is `<API_URL>/storage/v1/s3`, and it and the S3 access keys +and region come from the Storage member's saved configuration when its S3 +protocol is enabled. `ANON_KEY` and `SERVICE_ROLE_KEY` are emitted from the +required saved database JWT secret. `--override-name` renames an exported variable, accepting repeated flags or a comma-separated list of `EXPORTED_VARIABLE=VALID_ENV_NAME` entries. It requires diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 3c0d8c4e12..e751b24fb8 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -16,15 +16,14 @@ const variableNames = [ "SECRET_KEY", "ANON_KEY", "SERVICE_ROLE_KEY", -] as const; - -/** Names the stack backend used to export, since removed; still worth naming in errors. */ -const removedVariableNames = new Set([ + "STORAGE_S3_URL", "S3_PROTOCOL_ACCESS_KEY_ID", "S3_PROTOCOL_ACCESS_KEY_SECRET", "S3_PROTOCOL_REGION", - "S3_PROTOCOL_URL", -]); +] as const; + +/** Names the stack backend used to export, since removed; still worth naming in errors. */ +const removedVariableNames = new Set(["S3_PROTOCOL_URL"]); export const stackEnvOverrides = (entries: ReadonlyArray<string>) => Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index 383c8499e8..5b5dcf8ba3 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -95,7 +95,7 @@ describe("stack environment overrides", () => { }), ); - it.effect("accepts every current variable name and rejects removed S3_PROTOCOL names", () => + it.effect("accepts every current variable name and rejects removed ones", () => Effect.gen(function* () { for (const name of [ "API_URL", @@ -110,17 +110,15 @@ describe("stack environment overrides", () => { "SECRET_KEY", "ANON_KEY", "SERVICE_ROLE_KEY", - ]) { - const names = yield* stackEnvOverrides([`${name}=RENAMED_${name}`]); - expect(names.get(name)).toBe(`RENAMED_${name}`); - } - for (const removed of [ + "STORAGE_S3_URL", "S3_PROTOCOL_ACCESS_KEY_ID", "S3_PROTOCOL_ACCESS_KEY_SECRET", "S3_PROTOCOL_REGION", - "S3_PROTOCOL_URL", - "JWT_SECRET", ]) { + const names = yield* stackEnvOverrides([`${name}=RENAMED_${name}`]); + expect(names.get(name)).toBe(`RENAMED_${name}`); + } + for (const removed of ["S3_PROTOCOL_URL", "JWT_SECRET"]) { const error = yield* stackEnvOverrides([`${removed}=RENAMED`]).pipe(Effect.flip); expect(error.reason).toBe("flags"); } diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index cdced86d6f..ffa4890839 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -71,6 +71,18 @@ const functions: ServiceCreation = { }, endpoints: { http: { port: 54321 } }, }; +const storage = (s3ProtocolEnabled: boolean): ServiceCreation => ({ + service: "storage", + config: { + filePath: "/project/supabase/.temp/stack-uploads", + jwtSecret, + s3ProtocolEnabled, + s3AccessKeyId: "local-access-key", + s3SecretAccessKey: "local-secret-key", + s3Region: "local", + }, + endpoints: { http: { port: 54321 } }, +}); const flags = (input?: Partial<StackStatusFlags>): StackStatusFlags => ({ stack: Option.none(), stackId: Option.none(), @@ -583,6 +595,88 @@ it.live("omits MCP_URL when Studio is not a composition member", () => }), ); +const storageServices = (creation: ServiceCreation) => { + return [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "storage-id", + creation, + statusCalls: { value: 0 }, + observation: makeObservation("storage-id", creation, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + ]; +}; + +it.live("reports the Storage S3 endpoint and access keys through the gateway", () => + Effect.gen(function* () { + const text = yield* runStatus({ services: storageServices(storage(true)), reachable: true }); + yield* text.effect; + expect(text.out.stdoutText).toMatch(/URL +│ http:\/\/127\.0\.0\.1:54321\/storage\/v1\/s3 +│/u); + expect(text.out.stdoutText).toMatch(/Access Key +│ local-access-key +│/u); + expect(text.out.stdoutText).toMatch(/Secret Key +│ local-secret-key +│/u); + expect(text.out.stdoutText).toMatch(/Region +│ local +│/u); + const env = yield* runStatus({ + services: storageServices(storage(true)), + reachable: true, + flags: flags({ env: true }), + }); + yield* env.effect; + expect(env.out.stdoutText).toContain("STORAGE_S3_URL='http://127.0.0.1:54321/storage/v1/s3'"); + expect(env.out.stdoutText).toContain("S3_PROTOCOL_ACCESS_KEY_ID='local-access-key'"); + expect(env.out.stdoutText).toContain("S3_PROTOCOL_ACCESS_KEY_SECRET='local-secret-key'"); + expect(env.out.stdoutText).toContain("S3_PROTOCOL_REGION='local'"); + }), +); + +it.live("omits Storage S3 details when the S3 protocol is disabled", () => + Effect.gen(function* () { + const text = yield* runStatus({ services: storageServices(storage(false)), reachable: true }); + yield* text.effect; + expect(text.out.stdoutText).toMatch(/Project URL +│ http:\/\/127\.0\.0\.1:54321 +│/u); + expect(text.out.stdoutText).not.toContain("Storage (S3)"); + const env = yield* runStatus({ + services: storageServices(storage(false)), + reachable: true, + flags: flags({ env: true }), + }); + yield* env.effect; + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + expect(env.out.stdoutText).not.toContain("S3_PROTOCOL_"); + expect(env.out.stdoutText).not.toContain("STORAGE_S3_URL"); + }), +); + +it.live("omits Storage S3 details for a Storage member saved without S3 keys", () => + Effect.gen(function* () { + const env = yield* runStatus({ + services: storageServices({ + service: "storage", + config: { filePath: "/project/supabase/.temp/stack-uploads", jwtSecret }, + endpoints: { http: { port: 54321 } }, + }), + reachable: true, + flags: flags({ env: true }), + }); + yield* env.effect; + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + expect(env.out.stdoutText).not.toContain("S3_PROTOCOL_"); + expect(env.out.stdoutText).not.toContain("STORAGE_S3_URL"); + }), +); + for (const { functionsMember, status } of [ { functionsMember: false, status: "unchanged" }, { functionsMember: true, status: "unavailable" }, diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index 3a170071f8..036f245874 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -1,5 +1,6 @@ import { allowedEndpointNames, type ServiceCreation } from "../services/Catalog.ts"; import type { ServiceEndpoint } from "../services/Recipe.ts"; +import { apiPath as storageApiPath } from "../services/Storage.ts"; import type { NetworkEndpoint } from "../Network.ts"; import { ProxyError, type BackendAddress } from "../Proxy.ts"; import { Data, Effect, Redacted } from "effect"; @@ -37,7 +38,7 @@ export const apiRoute = (service: ServiceCreation["service"]): string | undefine case "auth": return "/auth/v1"; case "storage": - return "/storage/v1"; + return storageApiPath; case "functions": return "/functions/v1"; case "realtime": diff --git a/packages/stack/src/services/Storage.ts b/packages/stack/src/services/Storage.ts index bc97772f70..2018dfad64 100644 --- a/packages/stack/src/services/Storage.ts +++ b/packages/stack/src/services/Storage.ts @@ -1,4 +1,9 @@ import { Effect, Schema } from "effect"; +import { + DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + DEFAULT_LOCAL_S3_REGION, + DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, +} from "../Defaults.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { databaseConnection, requiredInput, localJwtSecret, serviceJwt } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; @@ -13,6 +18,9 @@ export const Config = Schema.Struct({ imgproxyUrl: Schema.optionalKey(Schema.String), fileSizeLimit: Schema.optionalKey(Schema.String), s3ProtocolEnabled: Schema.optionalKey(Schema.Boolean), + s3AccessKeyId: Schema.optionalKey(Schema.String), + s3SecretAccessKey: Schema.optionalKey(Schema.String), + s3Region: Schema.optionalKey(Schema.String), vectorEnabled: Schema.optionalKey(Schema.Boolean), vectorDatabaseUrl: Schema.optionalKey(Schema.String), vectorMaxBuckets: Schema.optionalKey(Schema.Finite), @@ -27,6 +35,9 @@ export const Creation = serviceCreation("storage", Config, Endpoints); export interface Creation extends Schema.Schema.Type<typeof Creation> {} +/** Path prefix the stack gateway strips before forwarding a request to Storage. */ +export const apiPath = "/storage/v1"; + export const initializationCommand = { args: [], containerEntrypoint: "/slim-runtime/bin/prepare", @@ -58,8 +69,11 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ AUTH_JWT_SECRET: jwt, PGRST_JWT_SECRET: jwt, ...(creation.config.jwks === undefined ? {} : { JWT_JWKS: creation.config.jwks }), + // The Storage image sets NODE_ENV=production, which forces https into TUS upload URLs. + NODE_ENV: "development", TENANT_ID: "stub", REGION: "local", + STORAGE_S3_REGION: creation.config.s3Region ?? DEFAULT_LOCAL_S3_REGION, GLOBAL_S3_BUCKET: "stub", STORAGE_BACKEND: "file", DB_HOST: db.host, @@ -69,6 +83,13 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ DB_NAME: db.database, FILE_STORAGE_BACKEND_PATH: filePath, STORAGE_FILE_BACKEND_PATH: filePath, + TUS_URL_PATH: `${apiPath}/upload/resumable`, + S3_PROTOCOL_PREFIX: apiPath, + S3_PROTOCOL_ACCESS_KEY_ID: creation.config.s3AccessKeyId ?? DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: + creation.config.s3SecretAccessKey ?? DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", ...(creation.config.s3ProtocolEnabled === undefined ? {} : { S3_PROTOCOL_ENABLED: String(creation.config.s3ProtocolEnabled) }), @@ -95,7 +116,7 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ ? {} : { IMGPROXY_URL: creation.config.imgproxyUrl, - ENABLE_IMAGE_TRANSFORMATION: "true", + IMAGE_TRANSFORMATION_ENABLED: "true", }), }; }), diff --git a/packages/stack/src/services/StorageGateway.integration.test.ts b/packages/stack/src/services/StorageGateway.integration.test.ts new file mode 100644 index 0000000000..857e6b9460 --- /dev/null +++ b/packages/stack/src/services/StorageGateway.integration.test.ts @@ -0,0 +1,153 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { tmpdir } from "node:os"; +import { + DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + DEFAULT_LOCAL_S3_REGION, + DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, +} from "../Defaults.ts"; +import * as State from "../State.ts"; +import type { SavedStack } from "../State.ts"; +import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { ownerFor } from "../../tests/owner-rpc.ts"; + +const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; +const jwtSecret = "storage-gateway-secret-with-at-least-32-chars"; +const s3Credentials = { + accessKeyId: DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + secretAccessKey: DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + region: DEFAULT_LOCAL_S3_REGION, +}; + +const layout = Effect.fnUntraced(function* (runtime: SavedStack["runtime"], stackId: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (runtime === "native") { + const root = yield* fs.makeTempDirectoryScoped({ prefix: "storage-gateway-native-" }); + return { stateRoot: `${root}/state`, dataRoot: `${root}/data`, storageRoot: `${root}/storage` }; + } + const dataRoot = yield* makeDockerDatabaseRoot("storage-gateway-docker-", stackId).pipe( + Effect.flatMap(fs.realPath), + ); + return { + stateRoot: path.dirname(path.dirname(dataRoot)), + dataRoot, + storageRoot: `${dataRoot}/storage`, + }; +}); + +/** Starts Database and Storage in an owned stack and returns Storage's gateway URL. */ +const serveStorage = Effect.fnUntraced(function* (runtime: SavedStack["runtime"]) { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const stackId = `storage-gateway-${runtime}-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; + const { stateRoot, dataRoot, storageRoot } = yield* layout(runtime, stackId); + yield* fs.makeDirectory(storageRoot, { recursive: true }); + const saved: SavedStack = { + id: stackId, + identity: { projectRoot: "/tmp/project", branchContext: "storage-gateway", stackName: stackId }, + runtime, + instances: [], + lifetime: "detached", + composition: { members: [], dependencies: [] }, + ports: [], + }; + const state = Context.get(yield* Layer.build(State.layer({ root: stateRoot })), State.Service); + yield* state.save(saved); + const owner = yield* ownerFor({ saved, state, root: dataRoot, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + const created = yield* owner.rpc.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + { + service: "storage", + config: { filePath: storageRoot, jwtSecret, s3ProtocolEnabled: true }, + endpoints: { http: { port: "auto" } }, + }, + ], + }); + const storage = created.find((entry) => entry.creation.service === "storage"); + if (storage === undefined) return yield* Effect.die("Storage member missing"); + yield* owner.rpc.startComposition(); + const { url } = yield* owner.rpc.credentials({ id: storage.id, from: "host" }); + if (url === undefined) return yield* Effect.die("Storage gateway URL missing"); + const { serviceRoleKey } = yield* owner.getStackCredentials; + return { url, serviceRoleKey }; +}); + +for (const runtime of ["native", "docker"] as const) + it.live( + `accepts S3 requests signed over the gateway path and resumes TUS uploads there (${runtime})`, + () => + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const { url, serviceRoleKey } = yield* serveStorage(runtime); + const bucket = "gateway"; + + const createBucket = yield* client.execute( + HttpClientRequest.post(`${url}/bucket`).pipe( + HttpClientRequest.setHeaders({ authorization: `Bearer ${serviceRoleKey}` }), + HttpClientRequest.bodyJsonUnsafe({ name: bucket }), + ), + ); + expect(createBucket.status, yield* createBucket.text).toBe(200); + const s3 = new Bun.S3Client({ ...s3Credentials, endpoint: `${url}/s3`, bucket }); + yield* Effect.promise(() => s3.write("signed.txt", "signed over the gateway")); + const listing = yield* Effect.promise(() => s3.list()); + expect(listing.contents?.map(({ key }) => key)).toEqual(["signed.txt"]); + expect(yield* Effect.promise(() => s3.file("signed.txt").text())).toBe( + "signed over the gateway", + ); + + const metadata = [ + ["bucketName", bucket], + ["objectName", "resumable.txt"], + ["contentType", "text/plain"], + ] + .map(([name, value]) => `${name} ${btoa(value ?? "")}`) + .join(","); + const create = yield* client.execute( + HttpClientRequest.post(`${url}/upload/resumable`).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${serviceRoleKey}`, + "tus-resumable": "1.0.0", + "upload-length": "11", + "upload-metadata": metadata, + }), + ), + ); + expect(create.status, yield* create.text).toBe(201); + const location = create.headers.location ?? ""; + expect(location.startsWith(`${url}/upload/resumable/`), location).toBe(true); + + const patch = yield* client.execute( + HttpClientRequest.patch(location).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${serviceRoleKey}`, + "tus-resumable": "1.0.0", + "upload-offset": "0", + }), + HttpClientRequest.bodyUint8Array( + new TextEncoder().encode("hello"), + "application/offset+octet-stream", + ), + ), + ); + expect(patch.status, yield* patch.text).toBe(204); + expect(patch.headers["upload-offset"]).toBe("5"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 180_000 }, + ); From 62aac0ff87aac7651f2066ecd5da2fe8078ca591 Mon Sep 17 00:00:00 2001 From: Julien Goux <hi@jgoux.dev> Date: Wed, 30 Sep 2026 18:48:49 +0000 Subject: [PATCH 69/71] feat(stack): pin slim artifacts by revision and make the catalog the single version table (#6883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary slim-services now publishes immutable `<upstream>-r<N>` releases (supabase/slim-services#326, #328). This PR makes the CLI consume them safely and from one place: - **Pinning.** Every slim artifact is pinned by content: the image digest, plus an archive and manifest sha256 per target. Nothing is looked up at runtime. - **One version table.** The stack catalog (`packages/stack/src/Artifacts.ts`) is the only place slim-capable service versions are written down. The new stack, legacy `supabase start`, and legacy slim mode (`SUPABASE_USE_SLIM_IMAGES`) all derive from it. - **Updates.** Upgrades and packaging hotfixes arrive as automated PRs from slim-services releases. ### Pinning - **Catalog entries.** They are now `ArtifactPin`s: - `upstreamVersion` - `revision` - `image: …:<U>-r<N>@sha256:…` - `upstreamImage`: the upstream image slim-services built from or mirrored, as recorded in the release - `natives`: sha256s per target - **Native downloads.** - The runtime `SHA256SUMS` and GHCR checksum lookups are gone. The GitHub release and S3 serve bytes only. - The manifest is hash-checked before it is parsed. - A mirror that serves the wrong bytes falls through to the next one. - The cache key is `slim-services/<svc>/<R>/<target>`, so a hotfix revision gets its own cache entry. ### One version table - **Generated Dockerfile lines.** The slim-capable `FROM` lines of `apps/cli/src/shared/services/Dockerfile`, and its Go copy, are generated from the catalog by `apps/cli/scripts/render-service-dockerfile.ts`. - The generator rewrites only those 14 lines, in place, and takes each repository from the existing line. imgproxy stays on `darthsim/imgproxy`. - Kong, `pg14`, and the job images (migra, pg_prove, pgadmin-schema-diff) are still maintained by hand or by Dependabot. - A drift test in the required `apps/cli` unit project fails when the Dockerfile and the catalog disagree. - **Postgres 13/15 and 14.** New `pg15` (generated) and `pg14` (hand-pinned) stages replace the hardcoded constants, in both the TS and the Go CLI. - Non-slim PG13/15 moves from 15.8.1.085 to the pinned 15 line. - Slim mode translates `pg15` through the catalog, like every other alias. - **Slim mode.** It now always finds its pin for the default versions. A linked project's hosted version that the catalog doesn't pin still uses the upstream image. ### Updates - **`slim-release-published.yml`** reconciles the dispatched service against every committed slim-services release. For each release line it opens, or rewrites in place: - a **hotfix** PR (`slim-hotfix/<svc>[-<line>]`), when the pinned upstream has a newer revision; - an **upgrade** PR (`slim-bump/<svc>[-<line>]`), when a newer upstream exists on that line. - **Each PR:** - pins exactly the planned release; - regenerates both Dockerfiles; - is assigned to `jgoux`. - **Mirror timing.** Before planning, the run waits (bounded) for the dispatched release to appear in the release list. Before pinning, it waits (bounded) for the S3 mirror of a new release, and a digest mismatch fails immediately. - **Trust.** Plan and apply run from one checkout of the default branch, so a re-run recomputes the plan and never applies a stale one. Every `::error ::`/`::warning ::` line uses workflow-command data encoding. The app token reaches only `git push` and `gh`: it is never written to `.git/config`, and every `bun`/`pnpm` command (the formatter runs from the locked install) runs without it. The PR lookup ignores fork PRs. Payload and release-tag values are validated against anchored patterns, and recorded image references are validated and escaped before they are written into the catalog. - **Dependabot.** It now ignores every slim-capable image, and the Dependabot-to-catalog sync (`sync-artifacts-catalog.yml`) is deleted. - **Tests.** They derive versions from the catalog, so a bump PR touches only `Artifacts.ts` and the two Dockerfiles. ### Other changes needed by the new versions - **Vector 0.58** no longer expands `${VAR}` in its config. - The recipe writes real values into its own config instead. - In caller-supplied pipelines it fills in only `LOGFLARE_URL` and `LOGFLARE_PRIVATE_ACCESS_TOKEN`. - It never enables `--dangerously-allow-env-var-interpolation`. - **Vector on legacy `supabase start`.** Vector 0.58's images don't ship `/etc/vector`, so the entrypoint creates it before writing the config. A `start.lifecycle` e2e scenario starts Vector against a real Logflare. - **`pgdelta.seam.layer.ts`** compares slim images by revision or digest, so a container on `-r0` is reported stale once the catalog moves to `-r1`. - **Three integration tests that failed under load now have coherent time budgets:** - `upload-release-assets`: the retry that must succeed had to fit in the same 500ms as the attempt that hangs on purpose. - `stack-shadow`: a real-Docker test ran under vitest's 5s default timeout. - `sweep-live-projects`: a 10s hard kill inside the test's budget. ### Docs ADR 0026 is rewritten for this model, including the tradeoffs: - slim-capable upstream and security fixes arrive only via slim-services releases; - Dependabot's cooldown no longer applies to them; - kong and `pg14` are bumped by hand; - the Deno 1 edge-runtime override stays pinned separately. `infra/cli-artifacts/README.md` is updated to match. ## Release notes - Unlinked local projects on Postgres 13 or 15 now start `supabase/postgres:15.19.0.002` instead of `15.8.1.085`. Later catalog upgrades on the 15 line move them within the same major. - `supabase start` runs the catalog's upstream versions (table below), and Storage is pinned to the `v1.79.28-r1` hotfix. ## Versions Every catalog entry pins its newest committed revision. Legacy `supabase start` now runs the same upstream versions: | Service | Catalog before → after | Legacy Dockerfile before → after | |---|---|---| | postgres 17 | `17.6.1.173` → `17.11.0.002-r0` | `17.6.1.171` → `17.11.0.002` | | postgres 15 (PG13/15) | `15.14.1.173` → `15.19.0.002-r0` | `15.8.1.085` (constant) → `15.19.0.002` | | postgrest | `v16.2` → `v16.4-r0` | `v16.3` → `v16.4` | | auth | `v2.196.0` → `v2.197.0-r0` | `v2.197.0` (unchanged) | | realtime | `v2.134.5` → `v2.140.3-r0` | `v2.135.3` → `v2.140.3` | | storage | `v1.73.0` → `v1.79.28-r1` | `v1.77.0` → `v1.79.28` | | imgproxy | `v3.8.0` → `v3.26.0-r0` | `v3.8.0` → `v3.26.0` | | edge-runtime | `v1.77.1` → `v1.77.1-r0` | `v1.77.1` (unchanged) | | studio | `2026.09.04-sha-5a67366` → `2026.09.28-sha-5e59b60-r0` | `2026.09.14-sha-4dd8a95` → `2026.09.28-sha-5e59b60` | | pgmeta | `v0.99.0` → `v0.99.0-r0` | `v0.99.0` (unchanged) | | mailpit | `v1.30.2` → `v1.31.3-r0` | `v1.30.2` → `v1.31.3` | | analytics | `v1.50.9` → `v1.50.15-r0` | `1.50.12` → `1.50.15` | | vector | `0.53.0` → `0.58.0-r0` | `0.53.0-alpine` → `0.58.0-alpine` | | pooler | `v2.9.12` → `v2.9.13-r0` | `2.9.13` (unchanged) | --- .github/dependabot.yml | 15 + .github/scripts/slim-mirror-payload.ts | 2 +- .../scripts/sync-artifacts-catalog.test.ts | 1367 ++++++++++++++- .github/scripts/sync-artifacts-catalog.ts | 1534 ++++++++++++++--- .github/workflows/mirror-template-images.yml | 7 +- .github/workflows/slim-release-published.yml | 227 +++ .github/workflows/sync-artifacts-catalog.yml | 98 -- apps/cli-go/pkg/config/config.go | 6 +- apps/cli-go/pkg/config/constants.go | 7 +- apps/cli-go/pkg/config/templates/Dockerfile | 28 +- ...nder-service-dockerfile.rules.unit.test.ts | 115 ++ apps/cli/scripts/render-service-dockerfile.ts | 192 +++ .../render-service-dockerfile.unit.test.ts | 66 + .../sweep-live-projects.integration.test.ts | 154 +- .../upload-release-assets.integration.test.ts | 20 +- .../db-bootstrap/pinned-image.unit.test.ts | 38 +- apps/cli/src/command-internal/db-image.ts | 2 +- .../command-internal/db-image.unit.test.ts | 62 +- .../edge-runtime-image.unit.test.ts | 9 +- .../stack-shadow.integration.test.ts | 105 +- .../shared/pgdelta.seam.integration.test.ts | 82 + .../commands/db/shared/pgdelta.seam.layer.ts | 38 +- .../db/start/start.integration.test.ts | 5 +- .../commands/services/services-local-stack.ts | 3 +- .../services/services.integration.test.ts | 74 +- .../commands/start/services/vector.service.ts | 15 +- .../services/vector.service.unit.test.ts | 2 +- .../start/start.lifecycle.e2e.test.ts | 51 + .../start/start.services.unit.test.ts | 24 +- .../start/start.slim-images.e2e.test.ts | 149 +- .../functions/functions.shared.unit.test.ts | 12 +- apps/cli/src/shared/services/Dockerfile | 28 +- .../services/dockerfile-go-sync.unit.test.ts | 20 - .../src/shared/services/services.shared.ts | 62 +- .../services/services.shared.unit.test.ts | 130 +- ...slim-images.catalog-alignment.unit.test.ts | 50 + apps/cli/src/shared/services/slim-images.ts | 79 +- .../shared/services/slim-images.unit.test.ts | 257 ++- apps/cli/tests/helpers/slim-images.ts | 29 + docs/adr/0026-slim-artifact-mirrors.md | 189 +- infra/cli-artifacts/README.md | 19 +- packages/stack/src/Artifacts.ts | 438 ++++- packages/stack/src/Artifacts.unit.test.ts | 11 +- ...dRunner.initialization.integration.test.ts | 18 +- packages/stack/src/internal/artifacts.ts | 4 + packages/stack/src/preparation/Integrity.ts | 2 +- .../src/preparation/SlimServicesSource.ts | 137 +- .../slim-services.integration.test.ts | 861 +++++---- .../DatabaseSnapshot.integration.test.ts | 9 +- .../ProcessRecipe.integration.test.ts | 24 +- packages/stack/src/services/Vector.ts | 97 +- .../DockerDatabaseStorage.integration.test.ts | 4 +- 52 files changed, 5385 insertions(+), 1592 deletions(-) create mode 100644 .github/workflows/slim-release-published.yml delete mode 100644 .github/workflows/sync-artifacts-catalog.yml create mode 100644 apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts create mode 100644 apps/cli/scripts/render-service-dockerfile.ts create mode 100644 apps/cli/scripts/render-service-dockerfile.unit.test.ts delete mode 100644 apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts create mode 100644 apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts create mode 100644 apps/cli/tests/helpers/slim-images.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3b6a0cd801..b66df12186 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -85,11 +85,26 @@ updates: update-types: - minor - patch + # These lines are generated from packages/stack/src/Artifacts.ts (the single version table + # for slim-capable services); updates to them arrive through slim-release-published.yml, not + # Dependabot. Kong and pg14 (supabase/postgres) have no slim build either, but are bumped by + # hand, not by Dependabot. Dependabot keeps bumping only the images that remain genuinely + # upstream-only: migra, pg_prove, pgadmin-schema-diff. ignore: - dependency-name: "library/kong" - dependency-name: "axllent/mailpit" - dependency-name: "darthsim/imgproxy" - dependency-name: "timberio/vector" + - dependency-name: "supabase/postgres" + - dependency-name: "supabase/gotrue" + - dependency-name: "postgrest/postgrest" + - dependency-name: "supabase/realtime" + - dependency-name: "supabase/storage-api" + - dependency-name: "supabase/edge-runtime" + - dependency-name: "supabase/studio" + - dependency-name: "supabase/postgres-meta" + - dependency-name: "supabase/logflare" + - dependency-name: "supabase/supavisor" cooldown: default-days: 7 exclude: diff --git a/.github/scripts/slim-mirror-payload.ts b/.github/scripts/slim-mirror-payload.ts index 74191dfebf..5b61fff58e 100644 --- a/.github/scripts/slim-mirror-payload.ts +++ b/.github/scripts/slim-mirror-payload.ts @@ -4,7 +4,7 @@ */ const SERVICE_PATTERN = /^[a-z][a-z0-9-]*$/; -export const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; +const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; export const SOURCE_REGISTRY = "ghcr.io/supabase/cli"; diff --git a/.github/scripts/sync-artifacts-catalog.test.ts b/.github/scripts/sync-artifacts-catalog.test.ts index 809dce551f..1a266d12be 100644 --- a/.github/scripts/sync-artifacts-catalog.test.ts +++ b/.github/scripts/sync-artifacts-catalog.test.ts @@ -1,132 +1,1331 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { InvalidPayloadError } from "./slim-mirror-payload.ts"; +import { InvalidPayloadError, nativeFileNames, nativeObjectUrl } from "./slim-mirror-payload.ts"; import { CATALOG_PATH, - planArtifactCatalogUpdate, - type ReleasePublication, + planSlimUpdates, + planUpdatesForService, + refreshCatalogPin, + resolveRevisionPin, + validateSlimReleasePublishedPayload, + waitForExpectedRelease, + type RevisionIo, } from "./sync-artifacts-catalog.ts"; -const DIGEST_B = `sha256:${"b".repeat(64)}`; -const POSTGRES_17 = `ghcr.io/supabase/cli/postgres:17.6.1.168@sha256:${"9".repeat(64)}`; -const POSTGRES_15 = `ghcr.io/supabase/cli/postgres:15.14.1.168@sha256:${"f".repeat(64)}`; +const NATIVE_TARGETS = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; + +const hex = (seed: string): string => new Bun.CryptoHasher("sha256").update(seed).digest("hex"); +const digest = (seed: string): string => `sha256:${hex(seed)}`; + +type NativeDigests = Record<(typeof NATIVE_TARGETS)[number], { archive: string; manifest: string }>; + +const nativeDigests = (seed: string): NativeDigests => ({ + "darwin-arm64": { archive: hex(`${seed}a`), manifest: hex(`${seed}A`) }, + "linux-amd64": { archive: hex(`${seed}b`), manifest: hex(`${seed}B`) }, + "linux-arm64": { archive: hex(`${seed}c`), manifest: hex(`${seed}C`) }, +}); + +/** Builds a `SHA256SUMS` body via the same file-name helper the sync script reads. */ +function checksumsFor(service: string, releaseVersion: string, digests: NativeDigests): string { + return NATIVE_TARGETS.map((target) => { + const files = nativeFileNames(service, releaseVersion, target); + return `${digests[target].archive} ${files.archive}\n${digests[target].manifest} ${files.manifest}`; + }).join("\n"); +} + +/** An `io` whose S3 copies always match the given checksums. */ +function matchingS3( + service: string, + releaseVersion: string, + digests: NativeDigests, +): RevisionIo["s3Sha256"] { + const byUrl = new Map<string, string>(); + for (const target of NATIVE_TARGETS) { + const files = nativeFileNames(service, releaseVersion, target); + byUrl.set(nativeObjectUrl(service, releaseVersion, files.archive), digests[target].archive); + byUrl.set(nativeObjectUrl(service, releaseVersion, files.manifest), digests[target].manifest); + } + return async (url) => byUrl.get(url); +} + +/** + * Runs the repo's pinned `oxfmt` binary over `source`, the way `slim-release-published.yml` + * formats the catalog after every write, so parsing tests exercise real formatter output + * (line-wrapping, trailing commas) instead of a hand-written single-line literal. + */ +async function formatWithOxfmt(source: string): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), "sync-artifacts-catalog-")); + const filePath = join(dir, "Artifacts.ts"); + try { + await writeFile(filePath, source); + const proc = Bun.spawn(["node_modules/.bin/oxfmt", "--config", ".oxfmtrc.json", filePath], { + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); + if (exitCode !== 0) throw new Error(`oxfmt failed: ${stderr}`); + return await readFile(filePath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** + * A resolved `ArtifactPin` literal, for fixtures that exercise refreshing an already-resolved + * catalog entry to a new revision (the committed catalog only carries resolved pins). `seed` keys a real, distinct digest via the module's `digest` helper. + */ +function resolvedPinLiteral( + service: string, + version: string, + revision: number, + seed: string, +): string { + return `{ upstreamVersion: "${version}", revision: ${revision}, image: "ghcr.io/supabase/cli/${service}:${version}-r${revision}@${digest(seed)}", natives: {} }`; +} + +const postgres17Pin = resolvedPinLiteral("postgres", "17.6.1.168", 0, "postgres-17"); +/** + * The default pin's `image` field, quoted. A formatter may move a long property value onto its + * own line, but never breaks a string literal's contents — so this survives real formatting the + * way asserting the whole (potentially re-wrapped) `postgres17Pin` literal would not. + */ +const postgres17Image = `"ghcr.io/supabase/cli/postgres:17.6.1.168-r0@${digest("postgres-17")}"`; const fixture = `const workloadCatalog = { database: definition( "postgres", - "17.6.1.168", - "${POSTGRES_17}", + ${postgres17Pin}, "bin/supabase-postgres-start", ["bin/supabase-postgres-start"], - { "15.14.1.168": "${POSTGRES_15}" }, + { "15.14.1.168": ${resolvedPinLiteral("postgres", "15.14.1.168", 0, "postgres-15")} }, ), - rest: definition("postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest"), + rest: definition("postgrest", ${resolvedPinLiteral("postgrest", "v16.2", 0, "postgrest")}, "bin/postgrest"), + storage: definition("storage", ${resolvedPinLiteral("storage", "v1.73.0", 0, "storage")}, "bin/storage", ["bin/storage"]), analytics: definition( "analytics", - "v1.50.9", - "ghcr.io/supabase/cli/analytics:v1.50.9@sha256:${"a".repeat(64)}", + ${resolvedPinLiteral("analytics", "v1.50.9", 0, "analytics")}, "bin/logflare", ), }; `; -const published = (digest?: string): ReleasePublication => ({ status: "published", digest }); - -describe("planArtifactCatalogUpdate", () => { - test("pins Dockerfile tags that changed and leaves the other postgres line", async () => { - const plan = await planArtifactCatalogUpdate({ - baseDockerfile: `FROM supabase/postgres:17.6.1.168 AS pg -FROM postgrest/postgrest:v16.2 AS postgrest -FROM supabase/logflare:1.50.9 AS logflare -`, - dockerfile: `FROM supabase/postgres:17.6.1.171 AS pg -FROM library/kong:2.8.1 AS kong -FROM postgrest/postgrest:v16.3 AS postgrest -FROM supabase/logflare:1.50.9 AS logflare -`, - catalog: fixture, - publication: async (service) => published(service === "postgres" ? DIGEST_B : undefined), +const vectorFixture = `const workloadCatalog = { + vector: definition("vector", ${resolvedPinLiteral("vector", "0.53.0", 0, "vector")}, "bin/vector"), +}; +`; + +/** + * Fixtures for `planSlimUpdates`: a committed catalog always carries resolved pins, so these + * give every pin a real revision. + */ +const plannerFixture = `const workloadCatalog = { + rest: definition( + "postgrest", + { + upstreamVersion: "v16.2", + revision: 0, + image: "ghcr.io/supabase/cli/postgrest:v16.2-r0@sha256:1111111111111111111111111111111111111111111111111111111111111", + natives: {}, + }, + "bin/postgrest", + ), + storage: definition( + "storage", + { + upstreamVersion: "v1.73.0", + revision: 0, + image: "ghcr.io/supabase/cli/storage:v1.73.0-r0@sha256:2222222222222222222222222222222222222222222222222222222222222", + natives: {}, + }, + "bin/storage", + ["bin/storage"], + ), +}; +`; + +/** Postgres carries two lines (17 default, 15 additional), both resolved. */ +const postgresPlannerFixture = `const workloadCatalog = { + database: definition( + "postgres", + { + upstreamVersion: "17.6.1.168", + revision: 1, + image: "ghcr.io/supabase/cli/postgres:17.6.1.168-r1@sha256:3333333333333333333333333333333333333333333333333333333333333", + natives: {}, + }, + "bin/supabase-postgres-start", + ["bin/supabase-postgres-start"], + { + "15.14.1.168": { + upstreamVersion: "15.14.1.168", + revision: 3, + image: "ghcr.io/supabase/cli/postgres:15.14.1.168-r3@sha256:4444444444444444444444444444444444444444444444444444444444444", + natives: {}, + }, + }, + ), +}; +`; + +describe("validateSlimReleasePublishedPayload", () => { + test("rejects a newline injected into upstream_version", () => { + expect(() => + validateSlimReleasePublishedPayload({ + service: "postgres", + upstream_version: "15.14.1.168\nrevision=999\ninjected=yes", + revision: "0", + release_version: "15.14.1.168\nrevision=999\ninjected=yes-r0", + }), + ).toThrow(InvalidPayloadError); + }); + + test("a newline-bearing service value produces a single-line error", () => { + let caught: unknown; + try { + validateSlimReleasePublishedPayload({ + service: "postgres\n::error ::injected", + upstream_version: "15.14.1.168", + revision: "0", + release_version: "15.14.1.168-r0", + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(InvalidPayloadError); + const message = (caught as InvalidPayloadError).message; + expect(message.split("\n")).toHaveLength(1); + expect(message).toContain(JSON.stringify("postgres\n::error ::injected")); + }); + + test("accepts a Studio-style calendar-versioned upstream", () => { + const payload = validateSlimReleasePublishedPayload({ + service: "studio", + upstream_version: "2026.09.04-sha-5a67366", + revision: "1", + release_version: "2026.09.04-sha-5a67366-r1", }); - expect(plan.updates.map((update) => `${update.service} ${update.version}`)).toEqual([ - "postgres 17.6.1.171", - "postgrest v16.3", + expect(payload).toEqual({ + service: "studio", + upstream_version: "2026.09.04-sha-5a67366", + revision: 1, + release_version: "2026.09.04-sha-5a67366-r1", + }); + }); + + test("accepts a Postgres four-part upstream version", () => { + const payload = validateSlimReleasePublishedPayload({ + service: "postgres", + upstream_version: "15.14.1.168", + revision: "3", + release_version: "15.14.1.168-r3", + }); + + expect(payload).toEqual({ + service: "postgres", + upstream_version: "15.14.1.168", + revision: 3, + release_version: "15.14.1.168-r3", + }); + }); + + test("rejects a release_version that does not match the derived value", () => { + expect(() => + validateSlimReleasePublishedPayload({ + service: "postgres", + upstream_version: "15.14.1.168", + revision: "3", + release_version: "15.14.1.168-r4", + }), + ).toThrow(InvalidPayloadError); + }); +}); + +describe("planSlimUpdates", () => { + test("hotfix only: a higher committed revision of the pinned upstream", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2-r1", + ]); + + expect(warnings).toEqual([]); + expect(updates).toEqual([ + { + kind: "hotfix", + line: undefined, + branch: "slim-hotfix/postgrest", + title: "chore(stack): pin postgrest v16.2-r1", + fromRelease: "v16.2-r0", + toUpstream: "v16.2", + toRelease: "v16.2-r1", + }, ]); - expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:17.6.1.171@${DIGEST_B}"`); - expect(plan.source).toContain(`"${POSTGRES_15}"`); - expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgrest:v16.3"`); - expect(plan.source).toContain(`"v1.50.9"`); - expect(plan.skipped).toEqual([]); }); - test("a missing release blocks the commit and OrioleDB does not", async () => { - const movesPostgres = await planArtifactCatalogUpdate({ - baseDockerfile: "FROM supabase/postgres:17.6.1.168 AS pg\n", - dockerfile: `FROM supabase/postgres:17.6.1.171 AS pg -FROM postgrest/postgrest:v16.3 AS postgrest -`, - catalog: fixture, - publication: async () => ({ status: "missing" }), - }); - expect(movesPostgres.updates).toEqual([]); - expect(movesPostgres.source).toBe(fixture); - expect(movesPostgres.skipped.every((skip) => skip.blocking)).toBe(true); - - const oriole = await planArtifactCatalogUpdate({ - baseDockerfile: - "FROM supabase/postgres:17.6.1.168 AS pg\nFROM postgrest/postgrest:v16.2 AS postgrest\n", - dockerfile: `FROM supabase/postgres:16.0.0.1-orioledb AS pg -FROM postgrest/postgrest:v16.3 AS postgrest -`, - catalog: fixture, - publication: async () => published(), + test("upgrade only: a newer committed upstream on the same line", () => { + const { updates } = planSlimUpdates(plannerFixture, "postgrest", ["postgrest-v16.4-r0"]); + + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/postgrest", + title: "chore(stack): bump postgrest to v16.4-r0", + fromRelease: "v16.2-r0", + toUpstream: "v16.4", + toRelease: "v16.4-r0", + }, + ]); + }); + + test("both a hotfix and an upgrade can be planned in the same run", () => { + const { updates } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2-r1", + "postgrest-v16.4-r0", + ]); + + expect(updates).toEqual([ + { + kind: "hotfix", + line: undefined, + branch: "slim-hotfix/postgrest", + title: "chore(stack): pin postgrest v16.2-r1", + fromRelease: "v16.2-r0", + toUpstream: "v16.2", + toRelease: "v16.2-r1", + }, + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/postgrest", + title: "chore(stack): bump postgrest to v16.4-r0", + fromRelease: "v16.2-r0", + toUpstream: "v16.4", + toRelease: "v16.4-r0", + }, + ]); + }); + + test("an older committed upstream is a backlog republish: it plans nothing", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.1-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("postgres: upgrade on the 17 line, hotfix on the 15 line, a major-18 release ignored and warned about", () => { + const { updates, warnings } = planSlimUpdates(postgresPlannerFixture, "postgres", [ + "postgres-17.11.0.002-r0", + "postgres-15.14.1.168-r4", + "postgres-18.0.0.001-r0", + ]); + + // The ignored major-18 tag is warned about, but doesn't block the two valid updates + // alongside it (P1: a warning must never corrupt or swallow the plan). + expect(warnings).toEqual([ + "::warning ::postgres 18.0.0.001 is not on a release line packages/stack/src/Artifacts.ts carries for it; ignoring postgres-18.0.0.001-r0.", + ]); + expect(updates).toEqual([ + { + kind: "upgrade", + line: "17", + branch: "slim-bump/postgres-17", + title: "chore(stack): bump postgres to 17.11.0.002-r0", + fromRelease: "17.6.1.168-r1", + toUpstream: "17.11.0.002", + toRelease: "17.11.0.002-r0", + }, + { + kind: "hotfix", + line: "15", + branch: "slim-hotfix/postgres-15", + title: "chore(stack): pin postgres 15.14.1.168-r4", + fromRelease: "15.14.1.168-r3", + toUpstream: "15.14.1.168", + toRelease: "15.14.1.168-r4", + }, + ]); + }); + + test("Studio: a newer date upgrades; the same date with a different sha does not", () => { + const studioFixture = `const workloadCatalog = { + studio: definition( + "studio", + { + upstreamVersion: "2026.09.14-sha-aaaaaaa", + revision: 0, + image: "ghcr.io/supabase/cli/studio:2026.09.14-sha-aaaaaaa-r0@sha256:1111111111111111111111111111111111111111111111111111111111111", + natives: {}, + }, + "bin/studio", + ), +}; +`; + + const sameDate = planSlimUpdates(studioFixture, "studio", ["studio-2026.09.14-sha-bbbbbbb-r0"]); + expect(sameDate.updates).toEqual([]); + + const newerDate = planSlimUpdates(studioFixture, "studio", [ + "studio-2026.09.28-sha-ccccccc-r0", + ]); + expect(newerDate.updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/studio", + title: "chore(stack): bump studio to 2026.09.28-sha-ccccccc-r0", + fromRelease: "2026.09.14-sha-aaaaaaa-r0", + toUpstream: "2026.09.28-sha-ccccccc", + toRelease: "2026.09.28-sha-ccccccc-r0", + }, + ]); + }); + + test("Studio: a year rollover upgrades even though releaseLine differs (single-pin services accept any upstream)", () => { + const studioFixture = `const workloadCatalog = { + studio: definition( + "studio", + { + upstreamVersion: "2026.09.28-sha-5e59b60", + revision: 0, + image: "ghcr.io/supabase/cli/studio:2026.09.28-sha-5e59b60-r0@sha256:5555555555555555555555555555555555555555555555555555555555555", + natives: {}, + }, + "bin/studio", + ), +}; +`; + + const { updates, warnings } = planSlimUpdates(studioFixture, "studio", [ + "studio-2027.01.01-sha-abcdef0-r0", + ]); + + expect(warnings).toEqual([]); + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/studio", + title: "chore(stack): bump studio to 2027.01.01-sha-abcdef0-r0", + fromRelease: "2026.09.28-sha-5e59b60-r0", + toUpstream: "2027.01.01-sha-abcdef0", + toRelease: "2027.01.01-sha-abcdef0-r0", + }, + ]); + }); + + test("postgrest: a major-version bump upgrades even though releaseLine differs (single-pin services accept any upstream)", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v17.0-r0", + ]); + + expect(warnings).toEqual([]); + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/postgrest", + title: "chore(stack): bump postgrest to v17.0-r0", + fromRelease: "v16.2-r0", + toUpstream: "v17.0", + toRelease: "v17.0-r0", + }, + ]); + }); + + test("a non-comparable version (OrioleDB-style suffix) is ignored and warned about", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2-orioledb-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([ + "::warning ::postgrest v16.2-orioledb is not a comparable version; ignoring.", + ]); + }); + + test("a `%` in a non-comparable version is encoded, staying a single workflow-command line", () => { + // `.` in the tag pattern allows `%` (only a real newline can't reach this far — the pattern + // can't match across one), so a real, legitimately-listed tag can still carry a `%` here. + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2%off-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([ + "::warning ::postgrest v16.2%25off is not a comparable version; ignoring.", + ]); + expect(warnings[0]?.split("\n")).toHaveLength(1); + }); + + test("a legacy tag with no -rN is ignored", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", ["postgrest-v16.4"]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("postgrest tags never match postgres, even as a prefix", () => { + const { updates } = planSlimUpdates(postgresPlannerFixture, "postgres", [ + "postgrest-v16.4-r0", + "postgres-17.11.0.002-r0", + ]); + + expect(updates).toEqual([ + { + kind: "upgrade", + line: "17", + branch: "slim-bump/postgres-17", + title: "chore(stack): bump postgres to 17.11.0.002-r0", + fromRelease: "17.6.1.168-r1", + toUpstream: "17.11.0.002", + toRelease: "17.11.0.002-r0", + }, + ]); + }); + + test("branch and title carry no -<line> suffix for a service with a single line", () => { + const { updates } = planSlimUpdates(plannerFixture, "storage", ["storage-v1.74.0-r0"]); + + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/storage", + title: "chore(stack): bump storage to v1.74.0-r0", + fromRelease: "v1.73.0-r0", + toUpstream: "v1.74.0", + toRelease: "v1.74.0-r0", + }, + ]); + }); + + test("rejects an emitted value that fails the anchored patterns, even from a trusted-looking catalog", () => { + // The catalog's own pinned upstream carries a space here — never written by `refreshCatalogPin` + // (which validates every field it resolves), but this simulates a corrupted catalog, or a + // release tag whose upstream portion isn't newline-only-unsafe (`.` already excludes + // newlines) yet still fails `UPSTREAM_VERSION_PATTERN`. Every value the planner emits is + // checked, regardless of source. + const adversarial = `const workloadCatalog = { + rest: definition( + "postgrest", + { + upstreamVersion: "v16.2 injected", + revision: 0, + image: "ghcr.io/supabase/cli/postgrest:v16.2-r0@sha256:2222222222222222222222222222222222222222222222222222222222222", + natives: {}, + }, + "bin/postgrest", + ), +}; +`; + + expect(() => + planSlimUpdates(adversarial, "postgrest", ["postgrest-v16.2 injected-r1"]), + ).toThrow(InvalidPayloadError); + }); + + test("plans nothing for a service the catalog does not model, and warns about it", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "no-such-service", [ + "no-such-service-v1.0-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([ + "::warning ::packages/stack/src/Artifacts.ts has no slim entry for no-such-service; nothing to plan.", + ]); + }); +}); + +describe("planUpdatesForService (the plan-updates transport's IO seam)", () => { + test("an ignored tag alongside a valid update: the valid record comes back, plus a separate warning", async () => { + const result = await planUpdatesForService({ + catalog: postgresPlannerFixture, + service: "postgres", + listReleaseTags: async () => [ + "postgres-17.11.0.002-r0", + "postgres-18.0.0.001-r0", // ignored: no line 18 + ], }); - expect(oriole.updates.map((update) => update.service)).toEqual(["postgrest"]); - expect(oriole.skipped).toEqual([ + + expect(result.warnings).toEqual([ + "::warning ::postgres 18.0.0.001 is not on a release line packages/stack/src/Artifacts.ts carries for it; ignoring postgres-18.0.0.001-r0.", + ]); + expect(result.updates).toEqual([ { - alias: "pg", - reason: "pg supabase/postgres:16.0.0.1-orioledb has no slim image.", - blocking: false, + kind: "upgrade", + line: "17", + branch: "slim-bump/postgres-17", + title: "chore(stack): bump postgres to 17.11.0.002-r0", + fromRelease: "17.6.1.168-r1", + toUpstream: "17.11.0.002", + toRelease: "17.11.0.002-r0", }, ]); - expect(oriole.source).toContain(`"${POSTGRES_17}"`); + }); +}); + +describe("waitForExpectedRelease (item A's eventual-consistency wait, call counts a subprocess can't assert)", () => { + test("visible on the first listing: returns immediately without waiting", async () => { + let calls = 0; + const waits: number[] = []; + const result = await waitForExpectedRelease( + "postgrest", + "v16.4-r1", + { + listReleaseTags: async () => { + calls += 1; + return ["postgrest-v16.4-r1"]; + }, + wait: async (ms) => { + waits.push(ms); + }, + }, + 6, + 10_000, + ); + + expect(result).toEqual({ visible: true, tags: ["postgrest-v16.4-r1"] }); + expect(calls).toBe(1); + expect(waits).toEqual([]); + }); + + test("visible on a later listing: re-lists and waits between attempts until it appears", async () => { + let calls = 0; + const waits: number[] = []; + const result = await waitForExpectedRelease( + "postgrest", + "v16.4-r1", + { + listReleaseTags: async () => { + calls += 1; + return calls < 3 ? ["postgrest-v16.4-r0"] : ["postgrest-v16.4-r0", "postgrest-v16.4-r1"]; + }, + wait: async (ms) => { + waits.push(ms); + }, + }, + 6, + 10_000, + ); + + expect(result).toEqual({ visible: true, tags: ["postgrest-v16.4-r0", "postgrest-v16.4-r1"] }); + expect(calls).toBe(3); + expect(waits).toEqual([10_000, 10_000]); + }); + + test("never visible: exhausts every bounded attempt, waiting between but not after the last", async () => { + let calls = 0; + const waits: number[] = []; + const result = await waitForExpectedRelease( + "postgrest", + "v16.4-r1", + { + listReleaseTags: async () => { + calls += 1; + return ["postgrest-v16.4-r0"]; + }, + wait: async (ms) => { + waits.push(ms); + }, + }, + 3, + 10, + ); + + expect(result).toEqual({ visible: false, tags: ["postgrest-v16.4-r0"] }); + expect(calls).toBe(3); + expect(waits).toEqual([10, 10]); + }); +}); + +describe("runPlanUpdates (the actual plan-updates CLI mode, not just the pure planner)", () => { + test("an ignored tag, a valid upgrade, and the expected dispatched release: --output gets exactly the valid record, the warning reaches stdout, and the process exits 0", async () => { + // Real catalog, real "postgrest" pin — a local fixture server stands in for the releases API + // (`SLIM_SERVICES_RELEASES_API`), so this exercises the real subprocess: the CLI's argv + // parsing, `--expect-release`'s visibility wait, the network lister, and the file/stdout + // wiring together, not just the pure planner or an in-process stub. + const catalog = await Bun.file(CATALOG_PATH).text(); + const pinMatch = + /definition\(\s*"postgrest",\s*\{\s*upstreamVersion:\s*"([^"]+)",\s*revision:\s*(\d+)/.exec( + catalog, + ); + if (pinMatch === null) throw new Error("postgrest pin not found in the real catalog"); + const pinnedUpstream = pinMatch[1] as string; + const pinnedRevision = Number(pinMatch[2]); + // Higher than any real pinned postgrest version, so it always plans as an upgrade. + const dispatchedRelease = "v999.0-r0"; + + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json([ + { tag_name: `postgrest-${pinnedUpstream}-orioledb-r0`, draft: false }, // ignored + { tag_name: `postgrest-${dispatchedRelease}`, draft: false }, // the valid upgrade + ]), + }); + const dir = await mkdtemp(join(tmpdir(), "plan-updates-subprocess-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "postgrest", + "--format", + "lines", + "--expect-release", + dispatchedRelease, + "--output", + outputPath, + ], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...globalThis.process.env, + SLIM_SERVICES_RELEASES_API: `http://127.0.0.1:${server.port}`, + }, + }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain( + `::warning ::postgrest ${pinnedUpstream}-orioledb is not a comparable version; ignoring.`, + ); + const content = await readFile(outputPath, "utf8"); + expect(content).toBe( + `upgrade\x1fslim-bump/postgrest\x1fchore(stack): bump postgrest to ${dispatchedRelease}\x1f${dispatchedRelease}\x1f${pinnedUpstream}-r${pinnedRevision}\n`, + ); + } finally { + await server.stop(true); + await rm(dir, { recursive: true, force: true }); + } + }); + + test("the expected dispatched release never becomes visible: the process exits non-zero with an actionable error, and writes no output file", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => Response.json([{ tag_name: "postgrest-v16.4-r0", draft: false }]), + }); + const dir = await mkdtemp(join(tmpdir(), "plan-updates-subprocess-never-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "postgrest", + "--format", + "lines", + "--expect-release", + "v999.0-r0", + "--output", + outputPath, + ], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...globalThis.process.env, + SLIM_SERVICES_RELEASES_API: `http://127.0.0.1:${server.port}`, + SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS: "5", + }, + }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("::error ::"); + expect(stdout).toContain("postgrest-v999.0-r0 is not visible yet"); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await server.stop(true); + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a missing --output exits non-zero before any network call", async () => { + const proc = Bun.spawn( + ["bun", ".github/scripts/sync-artifacts-catalog.ts", "plan-updates", "--service", "auth"], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain( + "Usage: sync-artifacts-catalog.ts plan-updates --service <service> --output <path>", + ); + }); + + test("a newline-bearing --service is rejected before any listing or wait", async () => { + const dir = await mkdtemp(join(tmpdir(), "plan-updates-bad-service-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "auth\n::error ::injected", + "--output", + outputPath, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("invalid --service"); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a newline-bearing --format is rejected, producing exactly one ::error :: line (not JSON.stringify'd; the boundary encoder is what protects it)", async () => { + const dir = await mkdtemp(join(tmpdir(), "plan-updates-bad-format-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "auth", + "--output", + outputPath, + "--format", + "lines\n::error ::injected", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + const lines = stdout.trimEnd().split("\n"); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^::error ::/); + expect(lines[0]).toContain("%0A"); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test.each(["5ms", "9".repeat(309)])( + "an invalid SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS (%s) exits non-zero with a configuration error", + async (waitMs) => { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "postgrest", + "--expect-release", + "v999.0-r0", + "--output", + join(await mkdtemp(join(tmpdir(), "plan-updates-bad-wait-")), "slim-updates.tsv"), + ], + { + stdout: "pipe", + stderr: "pipe", + env: { ...globalThis.process.env, SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS: waitMs }, + }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS"); + expect(stdout).toContain("non-negative integer"); + }, + ); +}); + +describe("refreshCatalogPin", () => { + test("refreshes a service to the highest revision of its currently pinned upstream", async () => { + const digests = nativeDigests("g"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0", "analytics-v1.50.9-r1"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r1", digests), + imageDigest: async () => digest("h"), + s3Sha256: matchingS3("analytics", "v1.50.9-r1", digests), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.update).toEqual({ + service: "analytics", + version: "v1.50.9", + revision: 1, + previousVersion: "v1.50.9", + target: "default", + }); + expect(result.source).toContain("revision: 1"); + expect(result.source).toContain( + `image: "ghcr.io/supabase/cli/analytics:v1.50.9-r1@${digest("h")}"`, + ); + }); + + test("a resolved pin survives real formatting and can be refreshed again", async () => { + const first = nativeDigests("i"); + const firstIo: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0"], + fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r0", first), + imageDigest: async () => digest("j"), + s3Sha256: matchingS3("postgrest", "v16.2-r0", first), + }; + const written = await refreshCatalogPin({ + catalog: fixture, + service: "postgrest", + io: firstIo, + }); + expect(written.update?.revision).toBe(0); + + const formatted = await formatWithOxfmt(written.source); + // The formatter actually wrapped the literal onto several lines with trailing commas; + // otherwise this test would not be exercising what it claims to. + expect(formatted.split("\n").length).toBeGreaterThan(written.source.split("\n").length); + + const second = nativeDigests("k"); + const secondIo: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0", "postgrest-v16.2-r1"], + fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r1", second), + imageDigest: async () => digest("l"), + s3Sha256: matchingS3("postgrest", "v16.2-r1", second), + }; + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "postgrest", + io: secondIo, + }); + + expect(refreshed.update).toEqual({ + service: "postgrest", + version: "v16.2", + revision: 1, + previousVersion: "v16.2", + target: "default", + }); + expect(refreshed.source).toContain("revision: 1"); + expect(refreshed.source).toContain(second["darwin-arm64"].archive); + }); + + test("refreshes the Postgres 15 additional pin, including after formatting", async () => { + const digests = nativeDigests("m"); + const io: RevisionIo = { + listReleaseTags: async () => ["postgres-15.14.1.168-r0"], + fetchChecksums: async () => checksumsFor("postgres", "15.14.1.168-r0", digests), + imageDigest: async () => digest("n"), + s3Sha256: matchingS3("postgres", "15.14.1.168-r0", digests), + }; + + const written = await refreshCatalogPin({ + catalog: fixture, + service: "postgres", + upstream: "15.14.1.168", + io, + }); + expect(written.update).toEqual({ + service: "postgres", + version: "15.14.1.168", + revision: 0, + previousVersion: "15.14.1.168", + target: "additional", + }); + // The default (17.x) postgres line is untouched. + expect(written.source).toContain(postgres17Image); + + const formatted = await formatWithOxfmt(written.source); + const nextDigests = nativeDigests("o"); + const nextIo: RevisionIo = { + listReleaseTags: async () => ["postgres-15.14.1.168-r0", "postgres-15.14.1.168-r1"], + fetchChecksums: async () => checksumsFor("postgres", "15.14.1.168-r1", nextDigests), + imageDigest: async () => digest("p"), + s3Sha256: matchingS3("postgres", "15.14.1.168-r1", nextDigests), + }; + + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "postgres", + upstream: "15.14.1.168", + io: nextIo, + }); + + expect(refreshed.update).toEqual({ + service: "postgres", + version: "15.14.1.168", + revision: 1, + previousVersion: "15.14.1.168", + target: "additional", + }); + expect(refreshed.source).toContain(postgres17Image); + expect(refreshed.source).toContain(nextDigests["linux-arm64"].manifest); + }); + + test("moves the Postgres 15 additional pin to a new upstream version, updating its key", async () => { + const digests = nativeDigests("u"); + const io: RevisionIo = { + listReleaseTags: async () => ["postgres-15.19.0.002-r0"], + fetchChecksums: async () => checksumsFor("postgres", "15.19.0.002-r0", digests), + imageDigest: async () => digest("v"), + s3Sha256: matchingS3("postgres", "15.19.0.002-r0", digests), + }; + + const written = await refreshCatalogPin({ + catalog: fixture, + service: "postgres", + upstream: "15.19.0.002", + io, + }); + expect(written.update).toEqual({ + service: "postgres", + version: "15.19.0.002", + revision: 0, + previousVersion: "15.14.1.168", + target: "additional", + }); + // The old key is gone; the new key matches the resolved pin's `upstreamVersion`. + expect(written.source).not.toContain('"15.14.1.168"'); + expect(written.source).toContain('"15.19.0.002": { upstreamVersion: "15.19.0.002"'); + // The default (17.x) postgres line is untouched. + expect(written.source).toContain(postgres17Image); + + // The renamed key resolves the entry again after real formatting, e.g. for a later hotfix. + const formatted = await formatWithOxfmt(written.source); + const nextDigests = nativeDigests("w"); + const nextIo: RevisionIo = { + listReleaseTags: async () => ["postgres-15.19.0.002-r0", "postgres-15.19.0.002-r1"], + fetchChecksums: async () => checksumsFor("postgres", "15.19.0.002-r1", nextDigests), + imageDigest: async () => digest("x"), + s3Sha256: matchingS3("postgres", "15.19.0.002-r1", nextDigests), + }; + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "postgres", + upstream: "15.19.0.002", + io: nextIo, + }); + + expect(refreshed.update).toEqual({ + service: "postgres", + version: "15.19.0.002", + revision: 1, + previousVersion: "15.19.0.002", + target: "additional", + }); + expect(refreshed.source).toContain(nextDigests["linux-arm64"].manifest); + }); +}); + +describe("resolveRevisionPin waits for the S3 mirror", () => { + test("waits while one S3 object is missing, then resolves once it appears", async () => { + const digests = nativeDigests("s3-wait"); + const releaseVersion = "v16.2-r0"; + const missingUrl = nativeObjectUrl( + "postgrest", + releaseVersion, + nativeFileNames("postgrest", releaseVersion, "darwin-arm64").archive, + ); + const present = matchingS3("postgrest", releaseVersion, digests); + let missingCalls = 0; + const waits: number[] = []; + const io: RevisionIo = { + listReleaseTags: async () => [`postgrest-${releaseVersion}`], + fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), + imageDigest: async () => digest("s3-wait-digest"), + s3Sha256: async (url) => { + if (url === missingUrl) { + missingCalls += 1; + if (missingCalls < 2) return undefined; + } + return present(url); + }, + wait: async (ms) => { + waits.push(ms); + }, + }; + + const resolution = await resolveRevisionPin("postgrest", "v16.2", io); + + expect(resolution.status).toBe("resolved"); + expect(missingCalls).toBe(2); + expect(waits).toHaveLength(1); }); - test("refuses a backward pin and a tag that would escape the catalog string", async () => { - const backward = await planArtifactCatalogUpdate({ - baseDockerfile: "FROM supabase/postgres:17.6.1.170 AS pg\n", - dockerfile: "FROM supabase/postgres:17.6.1.171 AS pg\n", - catalog: fixture.replaceAll("17.6.1.168", "17.6.1.173"), - publication: async () => { - throw new Error("publication lookup"); + test("fails immediately on a digest mismatch, without waiting", async () => { + const digests = nativeDigests("s3-mismatch"); + const releaseVersion = "v16.2-r0"; + const waits: number[] = []; + const io: RevisionIo = { + listReleaseTags: async () => [`postgrest-${releaseVersion}`], + fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), + imageDigest: async () => digest("s3-mismatch-digest"), + s3Sha256: async () => hex("wrong-bytes"), + wait: async (ms) => { + waits.push(ms); }, + }; + + const resolution = await resolveRevisionPin("postgrest", "v16.2", io); + + expect(resolution.status).toBe("stale"); + if (resolution.status !== "stale") throw new Error("expected status 'stale'"); + expect(resolution.message).toContain("digest mismatch"); + expect(waits).toEqual([]); + }); + + test("fails after the bounded attempts when an S3 object never appears, with an actionable message", async () => { + const digests = nativeDigests("s3-never"); + const releaseVersion = "v16.2-r0"; + const waits: number[] = []; + const io: RevisionIo = { + listReleaseTags: async () => [`postgrest-${releaseVersion}`], + fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), + imageDigest: async () => digest("s3-never-digest"), + s3Sha256: async () => undefined, + wait: async (ms) => { + waits.push(ms); + }, + }; + + const resolution = await resolveRevisionPin("postgrest", "v16.2", io); + + expect(resolution.status).toBe("stale"); + if (resolution.status !== "stale") throw new Error("expected status 'stale'"); + expect(resolution.message).toContain("mirror-slim-image.yml"); + expect(resolution.message).toContain("hasn't finished"); + expect(waits.length).toBeGreaterThan(0); + }); +}); + +describe("refreshCatalogPin --release", () => { + test("pins exactly the requested committed release, not the highest one", async () => { + const digests = nativeDigests("release-0"); + const io: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0", "postgrest-v16.2-r1"], + fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r0", digests), + imageDigest: async () => digest("release-digest-0"), + s3Sha256: matchingS3("postgrest", "v16.2-r0", digests), + }; + + const result = await refreshCatalogPin({ + catalog: fixture, + service: "postgrest", + release: "v16.2-r0", + io, }); - expect(backward.updates).toEqual([]); - expect(backward.skipped.map((skip) => skip.blocking)).toEqual([true]); - expect(backward.source).toContain(`"17.6.1.173"`); + + expect(result.update).toEqual({ + service: "postgrest", + version: "v16.2", + revision: 0, + previousVersion: "v16.2", + target: "default", + }); + }); + + test("fails when the requested release is not committed", async () => { + const io: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0"], + fetchChecksums: async () => undefined, + imageDigest: async () => undefined, + s3Sha256: async () => undefined, + }; await expect( - planArtifactCatalogUpdate({ - dockerfile: 'FROM supabase/gotrue:v1" AS gotrue\n', + refreshCatalogPin({ catalog: fixture, service: "postgrest", release: "v16.2-r1", io }), + ).rejects.toThrow(InvalidPayloadError); + }); + + test("rejects --upstream and --release given together", async () => { + const io: RevisionIo = { + listReleaseTags: async () => [], + fetchChecksums: async () => undefined, + imageDigest: async () => undefined, + s3Sha256: async () => undefined, + }; + + await expect( + refreshCatalogPin({ catalog: fixture, - publication: async () => published(), + service: "postgrest", + upstream: "v16.2", + release: "v16.2-r0", + io, }), ).rejects.toThrow(InvalidPayloadError); }); }); +describe("refreshCatalogPin backfills upstreamImage", () => { + test("resolves a derived service's upstreamImage from its per-target manifests", async () => { + const digests = nativeDigests("y"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0", "analytics-v1.50.9-r1"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r1", digests), + imageDigest: async () => digest("z"), + s3Sha256: matchingS3("analytics", "v1.50.9-r1", digests), + fetchManifest: async () => JSON.stringify({ upstream_image: "supabase/logflare:1.50.9" }), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); + }); + + test("falls back to source_image when a target's manifest has no upstream_image (image-derived build)", async () => { + const digests = nativeDigests("aa"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("ab"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => JSON.stringify({ source_image: "supabase/logflare:1.50.9" }), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); + }); + + test("strips a docker.io prefix and a digest from the resolved upstreamImage", async () => { + const digests = nativeDigests("ac"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("ad"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => + JSON.stringify({ upstream_image: "docker.io/supabase/logflare:1.50.9@sha256:deadbeef" }), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); + }); + + test("fails loudly when a derived service's manifests disagree across native targets", async () => { + const digests = nativeDigests("ae"); + let call = 0; + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("af"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => { + call += 1; + return JSON.stringify({ upstream_image: `supabase/logflare:1.50.${call}` }); + }, + }; + + await expect(refreshCatalogPin({ catalog: fixture, service: "analytics", io })).rejects.toThrow( + /manifests disagree/, + ); + }); + + test("resolves a mirrored service's upstreamImage from its oci-provenance source", async () => { + const digests = nativeDigests("ag"); + const io: RevisionIo = { + listReleaseTags: async () => ["vector-0.53.0-r0"], + fetchChecksums: async () => checksumsFor("vector", "0.53.0-r0", digests), + imageDigest: async () => digest("ah"), + s3Sha256: matchingS3("vector", "0.53.0-r0", digests), + fetchProvenance: async () => + JSON.stringify({ source: "docker.io/timberio/vector:0.53.0-alpine" }), + }; + + const result = await refreshCatalogPin({ catalog: vectorFixture, service: "vector", io }); + + expect(result.source).toContain('upstreamImage: "timberio/vector:0.53.0-alpine"'); + }); + + test("leaves upstreamImage absent when io has no manifest/provenance fetchers", async () => { + const digests = nativeDigests("ai"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("aj"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).not.toContain("upstreamImage"); + }); + + test("rejects a manifest upstream_image carrying a quote or template expression, writing nothing", async () => { + const digests = nativeDigests("ak"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("al"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => + JSON.stringify({ upstream_image: 'supabase/logflare:1.50.9"] }; import("evil"); //' }), + }; + + await expect(refreshCatalogPin({ catalog: fixture, service: "analytics", io })).rejects.toThrow( + InvalidPayloadError, + ); + }); +}); + describe("against the real catalog", () => { - test("every slim Dockerfile alias is a catalog entry", async () => { - const dockerfile = await Bun.file("apps/cli/src/shared/services/Dockerfile").text(); + test("a real catalog entry survives real formatting and can be refreshed again", async () => { const catalog = await Bun.file(CATALOG_PATH).text(); - const plan = await planArtifactCatalogUpdate({ - dockerfile, + const pinnedVersion = /definition\(\s*"auth",\s*\{\s*upstreamVersion:\s*"([^"]+)"/.exec( catalog, - publication: async () => published(`sha256:${"a".repeat(64)}`), + )?.[1]; + if (pinnedVersion === undefined) throw new Error("auth pin not found in the real catalog"); + + const first = nativeDigests("q"); + const firstIo: RevisionIo = { + listReleaseTags: async () => [`auth-${pinnedVersion}-r0`], + fetchChecksums: async () => checksumsFor("auth", `${pinnedVersion}-r0`, first), + imageDigest: async () => digest("r"), + s3Sha256: matchingS3("auth", `${pinnedVersion}-r0`, first), + }; + const written = await refreshCatalogPin({ catalog, service: "auth", io: firstIo }); + expect(written.update?.revision).toBe(0); + + const formatted = await formatWithOxfmt(written.source); + expect(formatted).toContain(`upstreamVersion: "${pinnedVersion}"`); + + const second = nativeDigests("s"); + const secondIo: RevisionIo = { + listReleaseTags: async () => [`auth-${pinnedVersion}-r0`, `auth-${pinnedVersion}-r1`], + fetchChecksums: async () => checksumsFor("auth", `${pinnedVersion}-r1`, second), + imageDigest: async () => digest("t"), + s3Sha256: matchingS3("auth", `${pinnedVersion}-r1`, second), + }; + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "auth", + io: secondIo, }); - expect( - plan.skipped.filter((skip) => skip.blocking && !skip.reason.includes("older than")), - ).toEqual([]); + expect(refreshed.update).toEqual({ + service: "auth", + version: pinnedVersion, + revision: 1, + previousVersion: pinnedVersion, + target: "default", + }); + expect(refreshed.source).toContain("revision: 1"); + expect(refreshed.source).toContain(second["darwin-arm64"].manifest); }); }); diff --git a/.github/scripts/sync-artifacts-catalog.ts b/.github/scripts/sync-artifacts-catalog.ts index 838f2c5b75..0ec85a6b02 100644 --- a/.github/scripts/sync-artifacts-catalog.ts +++ b/.github/scripts/sync-artifacts-catalog.ts @@ -1,28 +1,145 @@ /** - * Pins `packages/stack/src/Artifacts.ts` to the slim workloads named by - * `apps/cli/src/shared/services/Dockerfile`. Native archive URLs are derived - * from service + version. An entry that already carries a digest keeps one: - * the published `ghcr.io/supabase/cli/<service>:<version>` manifest digest. + * Pins `packages/stack/src/Artifacts.ts` to committed `supabase/slim-services` + * revisions (`<upstream>-r<N>`). Every entry is pinned by content: the GHCR + * image digest, plus an archive and manifest sha256 per native target. * - * Run: `bun .github/scripts/sync-artifacts-catalog.ts <dockerfile> <catalog> [base-dockerfile]` + * The catalog is the single version table (supabase/cli#6883): the Dockerfile's + * slim-capable lines are a generated view of it + * (`apps/cli/scripts/render-service-dockerfile.ts`), never the other way + * around. Updates land through two modes: + * + * Manual mode: refreshes one catalog entry, either to a specific committed + * release (`--release`) or to the highest committed revision of a given + * upstream version, or of its currently pinned upstream version when neither + * is given. `--upstream` and `--release` are mutually exclusive. + * + * bun .github/scripts/sync-artifacts-catalog.ts --service <service> [--upstream <U> | --release <U>-r<N>] + * + * Plan-updates mode (used by the `slim-release-published` dispatch workflow): + * lists a service's committed slim-services releases and computes, per + * release line, the hotfix and/or upgrade a workflow should apply. Pure + * planning: `planSlimUpdates` takes the release tag list as an argument, + * makes no network calls, and never logs — it returns `{ updates, warnings }`. + * Records go only to `--output <path>` (never stdout); warnings print to + * stdout as `::warning ::…` lines, so the two channels can't corrupt one + * another when a caller redirects stdout separately from the records file. + * + * bun .github/scripts/sync-artifacts-catalog.ts plan-updates --service <service> \ + * --output <path> [--format lines] [--expect-release <U>-r<N>] + * + * `--expect-release` handles the releases API lagging behind the dispatch that triggered this + * run: before planning, the listed committed tags must include `<service>-<release_version>`, or + * this mode re-lists a bounded number of times before giving up (`waitForExpectedRelease`). + * + * Validate-payload mode (used by the same workflow, before anything else): + * checks an untrusted `slim-release-published` dispatch payload against + * anchored charsets and prints it back as `key=value` lines, so a value that + * fails validation is never written to `$GITHUB_OUTPUT` in the first place. + * + * bun .github/scripts/sync-artifacts-catalog.ts validate-payload --service <service> \ + * --upstream <U> --revision <N> --release <R> */ -import { parseDockerfileServiceImages } from "../../apps/cli/src/shared/services/parse-dockerfile-service-images.ts"; -import { isOrioleImage, slimCatalogPin } from "../../apps/cli/src/shared/services/slim-images.ts"; import { DIGEST_PATTERN, InvalidPayloadError, SOURCE_REGISTRY, - VERSION_PATTERN, + checksumFor, escapeRegExp, + nativeFileNames, + nativeObjectUrl, } from "./slim-mirror-payload.ts"; export const CATALOG_PATH = "packages/stack/src/Artifacts.ts"; -const DOCKERFILE_PATH = "apps/cli/src/shared/services/Dockerfile"; -const NATIVE_RELEASES = "https://api.github.com/repos/supabase/slim-services/releases/tags"; const SLIM_IMAGE_PREFIX = `${SOURCE_REGISTRY}/`; +/** The three native targets the catalog pins per revision. Kept self-contained; see module docs. */ +const NATIVE_TARGETS = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; +type NativeTargetName = (typeof NATIVE_TARGETS)[number]; + +/** Overridable so an integration test can point at a local fixture server instead of GitHub. */ +const RELEASES_API = + process.env.SLIM_SERVICES_RELEASES_API ?? + "https://api.github.com/repos/supabase/slim-services/releases"; +const RELEASE_DOWNLOAD_BASE = "https://github.com/supabase/slim-services/releases/download"; + +/** Bounded retry for `waitForExpectedRelease`: 6 attempts, 10s apart, by default. */ +const EXPECT_RELEASE_ATTEMPTS = 6; +const DEFAULT_EXPECT_RELEASE_INTERVAL_MS = 10_000; +/** Bounded retry for the S3-mirror wait in `resolveRevisionPin`: ~10 min, covering a native upload of all three targets (`mirror-slim-image.yml`'s `upload-natives-s3`). */ +const S3_WAIT_ATTEMPTS = 20; +const DEFAULT_S3_WAIT_INTERVAL_MS = 30_000; +const MAX_TIMER_DELAY_MS = 2 ** 31 - 1; // setTimeout's own ceiling. + +/** + * Parses a millisecond wait-interval override from `envVar` fresh on every call, not once at + * module load, so a test can set it right before spawning. Unset keeps `fallback`; anything else + * must be a non-negative integer of at most `MAX_TIMER_DELAY_MS`, or this throws. + */ +function waitIntervalMs(envVar: string, fallback: number): number { + const raw = process.env[envVar]; + if (raw === undefined) return fallback; + const value = Number(raw); + if (!/^(0|[1-9][0-9]*)$/.test(raw) || value > MAX_TIMER_DELAY_MS) { + throw new InvalidPayloadError( + `invalid ${envVar} ${JSON.stringify(raw)}: expected a non-negative integer of at most ${MAX_TIMER_DELAY_MS}`, + ); + } + return value; +} + +const expectReleaseIntervalMs = (): number => + waitIntervalMs("SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS", DEFAULT_EXPECT_RELEASE_INTERVAL_MS); +const s3WaitIntervalMs = (): number => + waitIntervalMs("SLIM_UPDATES_S3_WAIT_MS", DEFAULT_S3_WAIT_INTERVAL_MS); + +/** Real delay, for a caller that didn't override `wait`. */ +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type BoundedCheck<T> = + | { readonly kind: "done"; readonly value: T } + | { readonly kind: "retry" } + | { readonly kind: "failed"; readonly message: string }; + +type BoundedWaitOutcome<T> = + | { readonly status: "done"; readonly value: T } + | { readonly status: "timed-out" } + | { readonly status: "failed"; readonly message: string }; + +/** + * Generic bounded retry-with-wait: `waitForExpectedRelease`'s release-visibility wait and + * `resolveRevisionPin`'s S3-mirror wait both build on this. Calls `check` up to `attempts` + * times, `wait`-ing `intervalMs` between, until it reports `"done"` or an unrecoverable + * `"failed"` (never waited out). Exhausting every attempt on `"retry"` yields `"timed-out"`. + */ +async function boundedWait<T>( + check: () => Promise<BoundedCheck<T>>, + wait: (ms: number) => Promise<void>, + attempts: number, + intervalMs: number, +): Promise<BoundedWaitOutcome<T>> { + for (let attempt = 0; attempt < attempts; attempt++) { + const result = await check(); + if (result.kind === "done") return { status: "done", value: result.value }; + if (result.kind === "failed") return { status: "failed", message: result.message }; + if (attempt < attempts - 1) await wait(intervalMs); + } + return { status: "timed-out" }; +} + +/** + * A GitHub Actions workflow-command line, `message` run through the workflow-command data + * encoding (`%` first, so encoding `\r`/`\n` never gets re-escaped) — the boundary every + * `::error ::`/`::warning ::` this script emits goes through. + */ +function workflowCommand(kind: "error" | "warning", message: string): string { + const encoded = message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + return `::${kind} ::${encoded}`; +} + /** Leading numeric component, `v` stripped. Only postgres carries more than one line. */ function releaseLine(version: string): string { const withoutPrefix = version.replace(/^[vV]/, ""); @@ -30,369 +147,1240 @@ function releaseLine(version: string): string { return separator === -1 ? withoutPrefix : withoutPrefix.slice(0, separator); } -/** True when every numeric prefix of `next` is older than `current`. Equal prefixes are not older. */ -function isOlderRelease(next: string, current: string): boolean { - const nextParts = next.replace(/^[vV]/, "").split("."); - const currentParts = current.replace(/^[vV]/, "").split("."); - const count = Math.max(nextParts.length, currentParts.length); - for (let index = 0; index < count; index++) { - const nextValue = leadingInteger(nextParts[index] ?? "0"); - const currentValue = leadingInteger(currentParts[index] ?? "0"); - if (nextValue === undefined || currentValue === undefined) return false; - if (nextValue !== currentValue) return nextValue < currentValue; - } - return false; +/** + * Matches a `<service>-<upstream>-r<N>` release tag. With `upstream` given, matches only that + * exact upstream (what `resolveRevisionPin` needs); with it omitted, captures any upstream as + * group 1 and the revision as group 2 (what the planner needs to enumerate every committed + * revision of every upstream a service carries). A tag with no `-r<N>` suffix — legacy — never + * matches either shape. + */ +function releaseTagPattern(service: string, upstream?: string): RegExp { + const upstreamPart = upstream === undefined ? "(.+)" : escapeRegExp(upstream); + return new RegExp(`^${escapeRegExp(service)}-${upstreamPart}-r(0|[1-9][0-9]*)$`); +} + +/** + * Strips a leading `v`/`V` and one trailing `-sha-<hex>`, then parses the remainder as dot- + * separated integers. Returns undefined when the remainder isn't `^\d+(\.\d+)*$` — a version that + * isn't comparable this way, such as an OrioleDB-style suffix. + */ +function comparableVersion(version: string): ReadonlyArray<number> | undefined { + const stripped = version.replace(/^[vV]/, "").replace(/-sha-[0-9a-f]+$/i, ""); + if (!/^\d+(\.\d+)*$/.test(stripped)) return undefined; + return stripped.split(".").map(Number); } -function leadingInteger(part: string): number | undefined { - const match = /^(\d+)/.exec(part); - return match === null ? undefined : Number(match[1]); +/** + * Numeric ordering of two upstream versions: `-1` when `a` is older, `0` when neither is newer + * (including two versions that only differ in a stripped `-sha-<hex>` suffix, e.g. two Studio + * builds on the same date), `1` when `a` is newer. Undefined when either isn't comparable. + */ +function compareVersions(a: string, b: string): number | undefined { + const av = comparableVersion(a); + const bv = comparableVersion(b); + if (av === undefined || bv === undefined) return undefined; + const length = Math.max(av.length, bv.length); + for (let index = 0; index < length; index++) { + const left = av[index] ?? 0; + const right = bv[index] ?? 0; + if (left !== right) return left < right ? -1 : 1; + } + return 0; } export interface CatalogPinUpdate { readonly service: string; readonly version: string; + readonly revision: number; readonly previousVersion: string; readonly target: "default" | "additional"; } -export interface SkippedCatalogPin { - readonly alias: string; - readonly reason: string; - /** OrioleDB has no slim image and must not fail the other pins. */ - readonly blocking: boolean; +/** Content pin for one resolved `<upstream>-r<N>` revision, ready to serialize into the catalog. */ +interface ResolvedPin { + readonly upstreamVersion: string; + readonly revision: number; + readonly image: string; + /** + * The pin's `ArtifactPin.upstreamImage`. Populated by manual mode (`refreshCatalogPin`) via + * `resolveUpstreamImage` below, when `io` carries `fetchManifest`/`fetchProvenance`. + */ + readonly upstreamImage?: string; + readonly natives: Readonly< + Record<NativeTargetName, { readonly archive: string; readonly manifest: string }> + >; } -export interface CatalogPlan { - readonly source: string; - readonly updates: ReadonlyArray<CatalogPinUpdate>; - readonly skipped: ReadonlyArray<SkippedCatalogPin>; +export type RevisionResolution = + | { readonly status: "resolved"; readonly pin: ResolvedPin } + | { + readonly status: "missing" | "incomplete" | "stale" | "lookup-failed"; + readonly message: string; + }; + +/** + * Network ports the resolver needs: the release list (for revision allocation), a release's + * `SHA256SUMS` body, the GHCR manifest digest, and a byte source's sha256. Tests stub these + * directly. + */ +export interface RevisionIo { + /** Tags of every published, non-draft release — a draft is not yet a committed revision. */ + readonly listReleaseTags: () => Promise<ReadonlyArray<string>>; + readonly fetchChecksums: (service: string, releaseVersion: string) => Promise<string | undefined>; + readonly imageDigest: (service: string, releaseVersion: string) => Promise<string | undefined>; + readonly s3Sha256: (url: string) => Promise<string | undefined>; + /** Overridable real delay between the S3-mirror wait's bounded attempts. */ + readonly wait?: (ms: number) => Promise<void>; + /** + * Raw contents of a native target's `.manifest.json` release asset, for a derived service's + * `upstream_image` (or `source_image` on an image-derived build, e.g. postgrest's Linux + * targets). Optional: only manual mode's `upstreamImage` backfill (`resolveUpstreamImage`) + * calls this, so a test `io` that doesn't exercise that path can omit it. + */ + readonly fetchManifest?: ( + service: string, + releaseVersion: string, + target: NativeTargetName, + ) => Promise<string | undefined>; + /** + * Raw contents of a mirrored service's `<service>-<upstreamVersion>.oci-provenance.json` + * release asset, whose `source` field is the mirrored upstream image. Optional for the same + * reason as `fetchManifest`. + */ + readonly fetchProvenance?: ( + service: string, + releaseVersion: string, + upstreamVersion: string, + ) => Promise<string | undefined>; } -export type ReleasePublication = - | { readonly status: "published"; readonly digest?: string } - | { readonly status: "missing" | "lookup-failed" }; +function desiredImage(service: string, releaseVersion: string, digest: string): string { + if (!DIGEST_PATTERN.test(digest)) { + throw new InvalidPayloadError(`missing slim digest for ${service}:${releaseVersion}`); + } + return `${SLIM_IMAGE_PREFIX}${service}:${releaseVersion}@${digest}`; +} -/** `definition("<service>", "<version>", "<image>"`. */ -function defaultEntryPattern(service: string): RegExp { - const s = escapeRegExp(service); - return new RegExp( - `(definition\\(\\s*"${s}",\\s*")([^"]+)("\\s*,\\s*")(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)(")`, - ); +/** + * Resolves `service`'s committed `<upstream>-r<N>` revision, pinned by content: the GHCR + * manifest digest, and every native target's archive and manifest sha256, cross-checked against + * the S3 mirror copy. + * + * With `requiredRevision` given, that exact revision must already be committed — this is what + * pins exactly a planned release (`--release`), never "highest at apply time". Without it, the + * highest committed revision of `upstream` is used (`--upstream`, and the hotfix/upgrade default). + */ +export async function resolveRevisionPin( + service: string, + upstream: string, + io: RevisionIo, + requiredRevision?: number, +): Promise<RevisionResolution> { + const tagPattern = releaseTagPattern(service, upstream); + const seenRevisions = new Set<number>(); + let highest: number | undefined; + for (const tag of await io.listReleaseTags()) { + const match = tagPattern.exec(tag); + if (match === null) continue; + const revision = Number(match[1]); + seenRevisions.add(revision); + if (highest === undefined || revision > highest) highest = revision; + } + + let target: number; + if (requiredRevision !== undefined) { + if (!seenRevisions.has(requiredRevision)) { + return { + status: "missing", + message: `${service}:${upstream}-r${requiredRevision} is not a committed slim-services release.`, + }; + } + target = requiredRevision; + } else { + if (highest === undefined) { + return { + status: "missing", + message: `${service}:${upstream} has no published slim-services revision.`, + }; + } + target = highest; + } + + const releaseVersion = `${upstream}-r${target}`; + const checksums = await io.fetchChecksums(service, releaseVersion); + if (checksums === undefined) { + return { + status: "incomplete", + message: `${service}-${releaseVersion} has no SHA256SUMS asset.`, + }; + } + + const natives: Record<string, { archive: string; manifest: string }> = {}; + for (const target of NATIVE_TARGETS) { + const files = nativeFileNames(service, releaseVersion, target); + const archive = checksumFor(checksums, files.archive); + const manifest = checksumFor(checksums, files.manifest); + if (archive === undefined || manifest === undefined) { + return { + status: "incomplete", + message: `${service}-${releaseVersion} SHA256SUMS has no line for ${ + archive === undefined ? files.archive : files.manifest + }.`, + }; + } + natives[target] = { archive, manifest }; + } + + const digest = await io.imageDigest(service, releaseVersion); + if (digest === undefined || !DIGEST_PATTERN.test(digest)) { + return { + status: "lookup-failed", + message: `${SLIM_IMAGE_PREFIX}${service}:${releaseVersion} has no published manifest.`, + }; + } + + // `mirror-slim-image.yml`'s S3 upload runs in parallel, best-effort, so a freshly committed + // revision's S3 copy can briefly lag GitHub: a missing object waits, bounded; a present object + // with the wrong bytes fails immediately instead of waiting. + for (const target of NATIVE_TARGETS) { + const files = nativeFileNames(service, releaseVersion, target); + for (const part of ["archive", "manifest"] as const) { + const url = nativeObjectUrl(service, releaseVersion, files[part]); + const expected = natives[target]?.[part] as string; + const outcome = await boundedWait<true>( + async () => { + const actual = await io.s3Sha256(url); + if (actual === undefined) { + console.log( + `Waiting for the S3 mirror of ${service}-${releaseVersion} ${target} (${part})...`, + ); + return { kind: "retry" }; + } + if (actual !== expected) { + return { + kind: "failed", + message: `S3 copy of ${releaseVersion} ${target} (${part}) is stale (digest mismatch); run the slim-services mirror backfill.`, + }; + } + return { kind: "done", value: true }; + }, + io.wait ?? sleep, + S3_WAIT_ATTEMPTS, + s3WaitIntervalMs(), + ); + if (outcome.status === "failed") { + return { status: "stale", message: outcome.message }; + } + if (outcome.status === "timed-out") { + return { + status: "stale", + message: `S3 copy of ${releaseVersion} ${target} (${part}) never appeared; the S3 mirror hasn't finished — check mirror-slim-image.yml for this release, backfill if needed, and re-run.`, + }; + } + } + } + + return { + status: "resolved", + pin: { + upstreamVersion: upstream, + revision: target, + image: desiredImage(service, releaseVersion, digest), + natives: natives as Record<NativeTargetName, { archive: string; manifest: string }>, + }, + }; } -/** Additional release entries: `"<version>": "<image>"`. */ -function additionalEntryPattern(service: string, version?: string): RegExp { - const s = escapeRegExp(service); - const key = version === undefined ? `[^"]+` : escapeRegExp(version); - return new RegExp( - `"(${key})"(\\s*:\\s*)"(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)"`, - version === undefined ? "g" : "", - ); +/** + * Services slim-services mirrors from an unmodified upstream image rather than building from + * source: their `upstreamImage` comes from the release's `oci-provenance.json` `source` field, + * not a native target's manifest. Keep this in sync with `SlimServicesSource`'s mirror-mode + * services. + */ +const MIRROR_MODE_SOURCE_SERVICES: ReadonlySet<string> = new Set(["vector", "mailpit", "imgproxy"]); + +function parseJsonRecord(raw: string): Record<string, unknown> | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + return typeof parsed === "object" && parsed !== null + ? (parsed as Record<string, unknown>) + : undefined; +} + +function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined { + const value = record?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; } -function imageHasDigest(image: string): boolean { - return /@sha256:[0-9a-f]{64}$/.test(image); +/** Strips a `docker.io/` prefix and any `@sha256:…` digest, to match the Dockerfile's `FROM` form. */ +function normalizeUpstreamImage(image: string): string { + const withoutDigest = image.split("@")[0] ?? image; + return withoutDigest.startsWith("docker.io/") + ? withoutDigest.slice("docker.io/".length) + : withoutDigest; } -function desiredImage( +/** + * A normalized `upstreamImage`: one or more lowercase `registry`/`repository` path segments + * (each `[a-z0-9]`, optionally separated internally by `.`/`_`/`-`), a `:`, and a tag matching + * Docker's own tag grammar. Anchored, so no whitespace, quote, or template/expression syntax can + * slip through — this value gets embedded as a TypeScript string literal in `Artifacts.ts`. + */ +const IMAGE_REFERENCE_PATTERN = + /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*:[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/; + +/** Validates a normalized `upstreamImage` before it's ever written to `Artifacts.ts`. */ +function validateUpstreamImage(image: string, context: string): string { + if (!IMAGE_REFERENCE_PATTERN.test(image)) { + throw new InvalidPayloadError( + `${context} has an invalid upstreamImage ${JSON.stringify(image)}.`, + ); + } + return image; +} + +/** + * Resolves `service`'s `upstreamImage` for the release `releaseVersion` (`<upstream>-r<N>`): + * a mirrored service's `oci-provenance.json` `source`, or a derived service's per-target + * manifest `upstream_image` (falling back to `source_image` for an image-derived build, e.g. + * postgrest's Linux targets) — cross-checked across every native target, so a derived service + * whose manifests disagree fails instead of silently picking one. Only manual mode + * (`refreshCatalogPin`) calls this; `io` must carry `fetchManifest`/`fetchProvenance`. + */ +async function resolveUpstreamImage( service: string, - version: string, - currentImage: string, - digest: string, -): string { - const tagged = `${SLIM_IMAGE_PREFIX}${service}:${version}`; - if (!imageHasDigest(currentImage)) return tagged; - if (!DIGEST_PATTERN.test(digest)) { - throw new InvalidPayloadError(`missing slim digest for ${service}:${version}`); + releaseVersion: string, + upstreamVersion: string, + io: RevisionIo, +): Promise<string> { + if (MIRROR_MODE_SOURCE_SERVICES.has(service)) { + if (io.fetchProvenance === undefined) { + throw new InvalidPayloadError( + `${service} needs an io.fetchProvenance to resolve upstreamImage.`, + ); + } + const provenance = await io.fetchProvenance(service, releaseVersion, upstreamVersion); + if (provenance === undefined) { + throw new InvalidPayloadError(`${service}-${releaseVersion} has no oci-provenance asset.`); + } + const source = stringField(parseJsonRecord(provenance), "source"); + if (source === undefined) { + throw new InvalidPayloadError( + `${service}-${releaseVersion} oci-provenance has no 'source' field.`, + ); + } + return validateUpstreamImage(normalizeUpstreamImage(source), `${service}-${releaseVersion}`); + } + + if (io.fetchManifest === undefined) { + throw new InvalidPayloadError(`${service} needs an io.fetchManifest to resolve upstreamImage.`); + } + const values = new Set<string>(); + for (const target of NATIVE_TARGETS) { + const manifest = await io.fetchManifest(service, releaseVersion, target); + if (manifest === undefined) { + throw new InvalidPayloadError( + `${service}-${releaseVersion}-${target} has no manifest asset.`, + ); + } + const record = parseJsonRecord(manifest); + const value = stringField(record, "upstream_image") ?? stringField(record, "source_image"); + if (value === undefined) { + throw new InvalidPayloadError( + `${service}-${releaseVersion}-${target} manifest has neither 'upstream_image' nor 'source_image'.`, + ); + } + values.add(normalizeUpstreamImage(value)); + } + if (values.size !== 1) { + throw new InvalidPayloadError( + `${service}-${releaseVersion} manifests disagree on the upstream image: ${[...values] + .sort() + .map((value) => JSON.stringify(value)) + .join(", ")}.`, + ); } - return `${tagged}@${digest}`; + return validateUpstreamImage([...values][0] as string, `${service}-${releaseVersion}`); +} + +/** + * Serializes a resolved pin into the object literal `Artifacts.ts` embeds, in catalog order. + * Every string field goes through `JSON.stringify`, not manual `"${…}"` interpolation — this + * text is written straight into TypeScript source that later gets imported, so an unescaped + * quote or template expression in any field (release metadata included) would inject code. + */ +function serializePin(pin: ResolvedPin): string { + const natives = NATIVE_TARGETS.map((target) => { + const native = pin.natives[target]; + return `${JSON.stringify(target)}: { archive: ${JSON.stringify(native.archive)}, manifest: ${JSON.stringify(native.manifest)} }`; + }).join(", "); + const upstreamImage = + pin.upstreamImage === undefined ? "" : ` upstreamImage: ${JSON.stringify(pin.upstreamImage)},`; + return `{ upstreamVersion: ${JSON.stringify(pin.upstreamVersion)}, revision: ${pin.revision}, image: ${JSON.stringify(pin.image)},${upstreamImage} natives: { ${natives} } }`; +} + +interface PinSpan { + readonly start: number; + readonly end: number; + readonly version: string; + /** + * Span of an additional (release-line-keyed) pin's own string key, content only (no quotes). + * Absent for the default pin, which carries no separate key to keep in sync. + */ + readonly key?: { readonly start: number; readonly end: number }; +} + +/** + * Index right after the matching close-bracket for the open-bracket character at `openIndex` + * (which must itself be `open`). Skips string contents, so a formatter's line-wrapping, trailing + * commas, or reordered properties never confuse the boundary — only bracket balance matters. + */ +function scanBalanced(source: string, openIndex: number, open: string, close: string): number { + let depth = 0; + let index = openIndex; + for (; index < source.length; index++) { + const ch = source[index]; + if (ch === '"' || ch === "'" || ch === "`") { + const quote = ch; + index++; + while (index < source.length && source[index] !== quote) { + if (source[index] === "\\") index++; + index++; + } + continue; + } + if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) return index + 1; + } + } + throw new InvalidPayloadError(`unterminated '${open}' while parsing ${CATALOG_PATH}`); +} + +/** Loosely finds `upstreamVersion` anywhere inside a resolved pin literal's text. */ +const PIN_UPSTREAM_VERSION = /upstreamVersion:\s*"([^"]+)"/; +/** Loosely finds `revision` anywhere inside a resolved pin literal's text. */ +const PIN_REVISION = /revision:\s*(\d+)/; + +/** + * Matches a resolved `ArtifactPin` object literal starting exactly at `index`. The span is found + * by bracket balance, then the version is pulled out with a loose field search — so a formatter's + * whitespace, line breaks, trailing commas, or property order never break matching, only the + * literal shape itself would. + */ +function matchPinAt(source: string, index: number): PinSpan | undefined { + if (source[index] === "{") { + const end = scanBalanced(source, index, "{", "}"); + const version = PIN_UPSTREAM_VERSION.exec(source.slice(index, end))?.[1]; + return version === undefined ? undefined : { start: index, end, version }; + } + return undefined; +} + +interface ServicePins { + readonly defaultPin: PinSpan; + readonly additional: ReadonlyArray<PinSpan>; +} + +/** + * Finds every pin expression `service` carries in `source`: the `definition("<service>", <pin>, + * ...)` default pin, plus any additional (release-line-keyed) pins in its trailing object + * argument. Both `selectEntry` and `planSlimUpdates` build on this single traversal. + */ +function collectServicePins(source: string, service: string): ServicePins | undefined { + const prefix = new RegExp(`definition\\(\\s*"${escapeRegExp(service)}"\\s*,\\s*`); + const prefixMatch = prefix.exec(source); + if (prefixMatch === null) return undefined; + const pinIndex = prefixMatch.index + prefixMatch[0].length; + const defaultPin = matchPinAt(source, pinIndex); + if (defaultPin === undefined) return undefined; + + const openParenIndex = prefixMatch.index + prefixMatch[0].indexOf("("); + const callEnd = scanBalanced(source, openParenIndex, "(", ")"); + + const additional: PinSpan[] = []; + const keyPattern = /"([^"]+)"\s*:\s*/g; + keyPattern.lastIndex = defaultPin.end; + for ( + let keyMatch = keyPattern.exec(source); + keyMatch !== null; + keyMatch = keyPattern.exec(source) + ) { + if (keyMatch.index >= callEnd) break; + const valueStart = keyMatch.index + keyMatch[0].length; + const pin = matchPinAt(source, valueStart); + if (pin === undefined) { + keyPattern.lastIndex = valueStart; + continue; + } + const keyText = keyMatch[1] ?? ""; + const keyStart = keyMatch.index + keyMatch[0].indexOf(keyText); + additional.push({ ...pin, key: { start: keyStart, end: keyStart + keyText.length } }); + keyPattern.lastIndex = pin.end; + } + + return { defaultPin, additional }; } type SelectedEntry = - | { readonly kind: "default" | "additional"; readonly version: string; readonly image: string } + | { readonly kind: "default" | "additional"; readonly version: string; readonly span: PinSpan } | { readonly kind: "unmodelled-service" } | { readonly kind: "unmodelled-release-line"; readonly known: ReadonlyArray<string> }; -function selectEntry(source: string, service: string, version: string): SelectedEntry { - const defaultMatch = defaultEntryPattern(service).exec(source); - if (defaultMatch === null) return { kind: "unmodelled-service" }; +/** + * Locates `service`'s pin expression in `source`: the `definition("<service>", <pin>, ...)` + * default pin, and, when `version` is given, whichever pin (default or additional) sits on its + * release line. With no `version`, returns the default pin unconditionally. + */ +function selectEntry(source: string, service: string, version: string | undefined): SelectedEntry { + const pins = collectServicePins(source, service); + if (pins === undefined) return { kind: "unmodelled-service" }; + const { defaultPin, additional } = pins; + + if (version === undefined) { + return { kind: "default", version: defaultPin.version, span: defaultPin }; + } - const currentDefaultVersion = defaultMatch[2] ?? ""; - const currentDefaultImage = defaultMatch[4] ?? ""; - const additional = [...source.matchAll(additionalEntryPattern(service))].map((match) => ({ - version: match[1] ?? "", - image: match[3] ?? "", - })); const bumpsDefault = - additional.length === 0 || releaseLine(version) === releaseLine(currentDefaultVersion); + additional.length === 0 || releaseLine(version) === releaseLine(defaultPin.version); if (bumpsDefault) { - return { kind: "default", version: currentDefaultVersion, image: currentDefaultImage }; + return { kind: "default", version: defaultPin.version, span: defaultPin }; } - - const sameLine = additional.find((entry) => releaseLine(entry.version) === releaseLine(version)); + const sameLine = additional.find((pin) => releaseLine(pin.version) === releaseLine(version)); if (sameLine === undefined) { return { kind: "unmodelled-release-line", - known: [currentDefaultVersion, ...additional.map((entry) => entry.version)], + known: [defaultPin.version, ...additional.map((pin) => pin.version)], }; } - return { kind: "additional", version: sameLine.version, image: sameLine.image }; + return { kind: "additional", version: sameLine.version, span: sameLine }; } -function skipReason(alias: string, version: string, entry: SelectedEntry): string | undefined { - if (entry.kind === "unmodelled-service") { - return `${CATALOG_PATH} has no slim entry for ${alias}.`; +/** The `{service, upstream_version, revision, release_version}` payload a `slim-release-published` dispatch carries. */ +export interface SlimReleasePublishedPayload { + readonly service: string; + readonly upstream_version: string; + readonly revision: number; + readonly release_version: string; +} + +/** Every upstream tag format the catalog carries, including Studio's `2026.09.04-sha-5a67366`. */ +const UPSTREAM_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SERVICE_NAME_PATTERN = /^[a-z0-9-]+$/; +const RELEASE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*-r(0|[1-9][0-9]*)$/; + +/** + * Validates an untrusted `slim-release-published` dispatch payload before any of its fields are + * written anywhere (a `$GITHUB_OUTPUT` line, a branch name, a PR title, …). Every field is checked + * against an anchored charset — `upstream_version` and `release_version` included, not just + * `service` and `revision` — so a value carrying a newline or shell/Actions metacharacter is + * rejected here instead of being echoed downstream. + */ +export function validateSlimReleasePublishedPayload(input: { + readonly service: string; + readonly upstream_version: string; + readonly revision: string; + readonly release_version: string; +}): SlimReleasePublishedPayload { + if (!SERVICE_NAME_PATTERN.test(input.service)) { + throw new InvalidPayloadError(`invalid service: ${JSON.stringify(input.service)}`); } - if (entry.kind === "unmodelled-release-line") { - return `${alias} ${version} is not on a release line ${CATALOG_PATH} carries (${entry.known.join(", ")}).`; + if (!UPSTREAM_VERSION_PATTERN.test(input.upstream_version)) { + throw new InvalidPayloadError( + `invalid upstream_version: ${JSON.stringify(input.upstream_version)}`, + ); } - return undefined; + if (!/^(0|[1-9][0-9]*)$/.test(input.revision)) { + throw new InvalidPayloadError(`invalid revision: ${JSON.stringify(input.revision)}`); + } + if (!RELEASE_VERSION_PATTERN.test(input.release_version)) { + throw new InvalidPayloadError( + `invalid release_version: ${JSON.stringify(input.release_version)}`, + ); + } + const revision = Number(input.revision); + const expected = `${input.upstream_version}-r${revision}`; + if (input.release_version !== expected) { + throw new InvalidPayloadError( + `release_version ${JSON.stringify(input.release_version)} does not match derived ${JSON.stringify(expected)}`, + ); + } + return { + service: input.service, + upstream_version: input.upstream_version, + revision, + release_version: input.release_version, + }; } -function slimVersions(dockerfile: string): ReadonlyMap<string, string> { - const versions = new Map<string, string>(); - for (const from of parseDockerfileServiceImages(dockerfile)) { - const pin = slimCatalogPin(from.alias, from.image); - if (pin !== undefined) versions.set(from.alias, pin.version); +const normalizeText = (text: string): string => text.replace(/\s+/g, " ").trim(); + +/** + * Writes `pin` over `entry`'s span in `source`, or leaves it unchanged when it already matches. + * An additional (release-line-keyed) pin also gets its own string key rewritten to `pin`'s + * `upstreamVersion` when it moves to a different upstream version, so the key an additional pin + * is looked up by never drifts from the `upstreamVersion` its resolved pin literal carries. + */ +function writePin( + source: string, + entry: Extract<SelectedEntry, { kind: "default" | "additional" }>, + pin: ResolvedPin, +): { readonly source: string; readonly changed: boolean } { + const desired = serializePin(pin); + const current = source.slice(entry.span.start, entry.span.end); + let next = source; + let changed = false; + if (normalizeText(current) !== normalizeText(desired)) { + next = next.slice(0, entry.span.start) + desired + next.slice(entry.span.end); + changed = true; } - return versions; + const key = entry.span.key; + if (key !== undefined && source.slice(key.start, key.end) !== pin.upstreamVersion) { + next = next.slice(0, key.start) + pin.upstreamVersion + next.slice(key.end); + changed = true; + } + return { source: next, changed }; } -type PinResult = - | { - readonly kind: "updated"; - readonly source: string; - readonly previousVersion: string; - readonly target: "default" | "additional"; - } - | { readonly kind: "unchanged" }; +/** + * One hotfix or upgrade a `slim-release-published` run should apply, on one of `service`'s + * release lines. `line` is the leading numeric component (`releaseLine`), present only when the + * service carries more than one line (only postgres does today) — it's already folded into + * `branch`, so a caller never needs to consume it separately. + */ +export interface SlimUpdate { + readonly kind: "hotfix" | "upgrade"; + readonly line?: string; + readonly branch: string; + readonly title: string; + /** The release version pinned before this update, e.g. `v2.195.0-r0`. */ + readonly fromRelease: string; + readonly toUpstream: string; + /** The release version this update pins, e.g. `v2.195.0-r1`. */ + readonly toRelease: string; +} -function pinService( - source: string, +/** + * Builds one `SlimUpdate`, validating every value it emits — `service`, `toUpstream` and + * `toRelease` — against the same anchored patterns `validateSlimReleasePublishedPayload` uses, + * before any of them reaches a branch name or PR title. Release tag names (the ultimate source of + * `toUpstream`) come from an external API, so this check applies even to values derived from the + * catalog itself (`fromRelease`'s upstream), not only to values freshly parsed from a tag. + */ +function buildUpdate( + kind: "hotfix" | "upgrade", service: string, - version: string, - digest: string | undefined, -): PinResult { - const entry = selectEntry(source, service, version); - if (entry.kind !== "default" && entry.kind !== "additional") { - throw new InvalidPayloadError(`${service} ${version} is not a catalog entry.`); + line: string, + hasLines: boolean, + pinned: { readonly upstream: string; readonly revision: number }, + toUpstream: string, + toRevision: number, +): SlimUpdate { + if (!SERVICE_NAME_PATTERN.test(service)) { + throw new InvalidPayloadError(`invalid service: ${JSON.stringify(service)}`); } - if (imageHasDigest(entry.image) && (digest === undefined || !DIGEST_PATTERN.test(digest))) { - throw new InvalidPayloadError(`missing slim digest for ${service}:${version}`); + if (!UPSTREAM_VERSION_PATTERN.test(toUpstream)) { + throw new InvalidPayloadError(`invalid upstream version: ${JSON.stringify(toUpstream)}`); } - const desired = desiredImage(service, version, entry.image, digest ?? ""); - if (entry.version === version && entry.image === desired) return { kind: "unchanged" }; - - if (entry.kind === "default") { - return { - kind: "updated", - source: source.replace( - defaultEntryPattern(service), - (_full, prefix: string, _version: string, mid: string, _image: string, suffix: string) => - `${prefix}${version}${mid}${desired}${suffix}`, - ), - previousVersion: entry.version, - target: "default", - }; + const toRelease = `${toUpstream}-r${toRevision}`; + if (!RELEASE_VERSION_PATTERN.test(toRelease)) { + throw new InvalidPayloadError(`invalid release version: ${JSON.stringify(toRelease)}`); } + const fromRelease = `${pinned.upstream}-r${pinned.revision}`; + const suffix = hasLines ? `-${line}` : ""; + const branch = + kind === "hotfix" ? `slim-hotfix/${service}${suffix}` : `slim-bump/${service}${suffix}`; + const title = + kind === "hotfix" + ? `chore(stack): pin ${service} ${toRelease}` + : `chore(stack): bump ${service} to ${toRelease}`; return { - kind: "updated", - source: source.replace( - additionalEntryPattern(service, entry.version), - (_full, _key: string, separator: string) => `"${version}"${separator}"${desired}"`, - ), - previousVersion: entry.version, - target: "additional", + kind, + line: hasLines ? line : undefined, + branch, + title, + fromRelease, + toUpstream, + toRelease, }; } +export interface PlanSlimUpdatesResult { + readonly updates: ReadonlyArray<SlimUpdate>; + /** `::warning ::…` workflow-command lines, for the caller to print to stdout. */ + readonly warnings: ReadonlyArray<string>; +} + +/** Groups every candidate release tag under one bucket, for a service with no additional pins. */ +const SINGLE_LINE_KEY = "*"; + /** - * Rewrites `catalog` from Dockerfile tags that differ from `baseDockerfile`. - * With no base, every slim tag is in scope. A pin that cannot be applied is - * reported in `skipped`. OrioleDB is the only non-blocking skip. + * The hotfix and/or upgrade a `slim-release-published` run should apply for `service`, computed + * against `catalog`'s current pins and `releaseTags` (every committed — published, non-draft — + * slim-services release tag; the caller filters drafts before calling this). Pure: no network, no + * file I/O, no logging — every diagnostic comes back in `warnings` instead, so a caller (the CLI, + * or a test) decides where it goes. This matters because `plan-updates` writes `updates` straight + * into a file the workflow parses as records; a `console.log`'d warning on the same stdout the + * workflow captures would corrupt that file instead of just being informational. + * + * Per release line (the default pin's line, plus one per additional pin — only postgres + * has more than one) a **hotfix** fires when the pinned upstream has a committed revision higher + * than the pinned one; an **upgrade** fires when the newest committed upstream on the line is + * newer than the pinned one (ties, e.g. two Studio builds dated the same day, are not newer). + * Both can fire in the same run. A service with no additional pins has exactly one line and + * accepts any comparable upstream on it — a Studio year rollover or a postgrest major bump is + * the same line moving forward, not a different one, so it is never filtered by `releaseLine`. + * Only a service that does carry additional pins (postgres) filters a release tag to the line its + * `releaseLine` names; a tag on no such line, or whose version isn't comparable, is warned about + * and ignored — it never causes a plan-updates run to fail. */ -export async function planArtifactCatalogUpdate(input: { - readonly dockerfile: string; - readonly baseDockerfile?: string; - readonly catalog: string; - readonly publication: (service: string, version: string) => Promise<ReleasePublication>; -}): Promise<CatalogPlan> { - let source = input.catalog; - const updates: CatalogPinUpdate[] = []; - const skipped: SkippedCatalogPin[] = []; - const baseVersions = - input.baseDockerfile === undefined ? undefined : slimVersions(input.baseDockerfile); - const changed = (alias: string, version: string | undefined): boolean => - baseVersions === undefined || baseVersions.get(alias) !== version; - - for (const from of parseDockerfileServiceImages(input.dockerfile)) { - if (isOrioleImage(from.image)) { - if (!changed(from.alias, undefined)) continue; - skipped.push({ - alias: from.alias, - reason: `${from.alias} ${from.image} has no slim image.`, - blocking: false, - }); - continue; +export function planSlimUpdates( + catalog: string, + service: string, + releaseTags: ReadonlyArray<string>, +): PlanSlimUpdatesResult { + const warnings: string[] = []; + const pins = collectServicePins(catalog, service); + if (pins === undefined) { + warnings.push( + workflowCommand( + "warning", + `${CATALOG_PATH} has no slim entry for ${service}; nothing to plan.`, + ), + ); + return { updates: [], warnings }; + } + + const allPins = [pins.defaultPin, ...pins.additional]; + const hasLines = pins.additional.length > 0; + + const pinnedByLine = new Map<string, { readonly upstream: string; readonly revision: number }>(); + for (const span of allPins) { + const line = hasLines ? releaseLine(span.version) : SINGLE_LINE_KEY; + const text = catalog.slice(span.start, span.end); + const revisionMatch = PIN_REVISION.exec(text); + if (revisionMatch === null) { + throw new InvalidPayloadError( + `${CATALOG_PATH} has no revision for ${service} ${span.version}; a committed catalog always pins a resolved revision.`, + ); } - const pin = slimCatalogPin(from.alias, from.image); - if (pin === undefined || !changed(from.alias, pin.version)) continue; - if (!VERSION_PATTERN.test(pin.version)) { - throw new InvalidPayloadError(`invalid version for ${from.alias}: '${pin.version}'`); + pinnedByLine.set(line, { upstream: span.version, revision: Number(revisionMatch[1]) }); + } + + const tagPattern = releaseTagPattern(service); + const candidatesByLine = new Map<string, Array<{ upstream: string; revision: number }>>(); + for (const tag of releaseTags) { + const match = tagPattern.exec(tag); + if (match === null) continue; // not this service, or a legacy tag with no `-rN`: ignored. + const upstream = match[1] as string; + const revision = Number(match[2]); + let line: string; + if (hasLines) { + line = releaseLine(upstream); + if (!pinnedByLine.has(line)) { + warnings.push( + workflowCommand( + "warning", + `${service} ${upstream} is not on a release line ${CATALOG_PATH} carries for it; ignoring ${tag}.`, + ), + ); + continue; + } + } else { + line = SINGLE_LINE_KEY; } + const list = candidatesByLine.get(line) ?? []; + list.push({ upstream, revision }); + candidatesByLine.set(line, list); + } - const entry = selectEntry(source, pin.service, pin.version); - const reason = skipReason(from.alias, pin.version, entry); - if (reason !== undefined) { - skipped.push({ alias: from.alias, reason, blocking: true }); - continue; + const updates: SlimUpdate[] = []; + for (const [line, pinned] of pinnedByLine) { + const candidates = candidatesByLine.get(line) ?? []; + + const sameUpstreamRevisions = candidates + .filter((candidate) => candidate.upstream === pinned.upstream) + .map((candidate) => candidate.revision); + if (sameUpstreamRevisions.length > 0) { + const highest = Math.max(...sameUpstreamRevisions); + if (highest > pinned.revision) { + updates.push( + buildUpdate("hotfix", service, line, hasLines, pinned, pinned.upstream, highest), + ); + } } - if (entry.kind !== "default" && entry.kind !== "additional") continue; - if (isOlderRelease(pin.version, entry.version)) { - skipped.push({ - alias: from.alias, - reason: `${pin.service} ${pin.version} is older than the catalog pin ${entry.version}.`, - blocking: true, - }); - continue; + + const highestRevisionByUpstream = new Map<string, number>(); + for (const candidate of candidates) { + const current = highestRevisionByUpstream.get(candidate.upstream); + if (current === undefined || candidate.revision > current) { + highestRevisionByUpstream.set(candidate.upstream, candidate.revision); + } } - if (entry.version === pin.version && !imageHasDigest(entry.image)) continue; - - const release = await input.publication(pin.service, pin.version); - if (release.status !== "published") { - skipped.push({ - alias: from.alias, - reason: - release.status === "missing" - ? `${pin.service}:${pin.version} has no published slim image and native release.` - : `${pin.service}:${pin.version} publication check failed.`, - blocking: true, - }); - continue; + + let bestUpstream: string | undefined; + for (const upstream of highestRevisionByUpstream.keys()) { + const comparedToPinned = compareVersions(upstream, pinned.upstream); + if (comparedToPinned === undefined) { + warnings.push( + workflowCommand( + "warning", + `${service} ${upstream} is not a comparable version; ignoring.`, + ), + ); + continue; + } + if (bestUpstream === undefined || (compareVersions(upstream, bestUpstream) ?? 0) > 0) { + bestUpstream = upstream; + } } - const digest = imageHasDigest(entry.image) ? release.digest : undefined; - if (imageHasDigest(entry.image) && (digest === undefined || !DIGEST_PATTERN.test(digest))) { - skipped.push({ - alias: from.alias, - reason: `${pin.service}:${pin.version} has no published slim manifest.`, - blocking: true, - }); - continue; + if (bestUpstream !== undefined && compareVersions(bestUpstream, pinned.upstream) === 1) { + const revision = highestRevisionByUpstream.get(bestUpstream) as number; + updates.push(buildUpdate("upgrade", service, line, hasLines, pinned, bestUpstream, revision)); } - const result = pinService(source, pin.service, pin.version, digest); - if (result.kind === "unchanged") continue; - source = result.source; - updates.push({ - service: pin.service, - version: pin.version, - previousVersion: result.previousVersion, - target: result.target, - }); } - return { source, updates, skipped }; + return { updates, warnings }; } -type Probe = "published" | "missing" | "lookup-failed"; +/** + * Reads every committed release tag through `listReleaseTags` and plans against `catalog`. The + * injectable lister is the seam a dry run or an end-to-end test uses to exercise the whole + * `plan-updates` transport — this function plus the CLI's file/stdout wiring — without a network + * call, the same pattern `RevisionIo.listReleaseTags` already uses. + */ +export async function planUpdatesForService(input: { + readonly catalog: string; + readonly service: string; + readonly listReleaseTags: () => Promise<ReadonlyArray<string>>; +}): Promise<PlanSlimUpdatesResult> { + const releaseTags = await input.listReleaseTags(); + return planSlimUpdates(input.catalog, input.service, releaseTags); +} + +export interface WaitForExpectedReleaseResult { + readonly visible: boolean; + /** The last listing `listReleaseTags` returned, whichever attempt it came from. */ + readonly tags: ReadonlyArray<string>; +} + +/** + * Eventual-consistency handling of the releases API lagging behind the `slim-release-published` + * dispatch that triggered this run: re-lists up to `attempts` times, `io.wait`-ing between + * attempts, until `<service>-<releaseVersion>` appears. Not a test retry — a real, bounded wait + * for an external API to catch up. `io.wait` is the seam a unit test overrides to skip the real + * delay; an integration test instead shrinks the real delay via `SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS` + * (parsed fresh by `expectReleaseIntervalMs` on every call this default reaches), since a + * subprocess can't be handed a function. + */ +export async function waitForExpectedRelease( + service: string, + releaseVersion: string, + io: { + readonly listReleaseTags: () => Promise<ReadonlyArray<string>>; + readonly wait: (ms: number) => Promise<void>; + }, + attempts: number = EXPECT_RELEASE_ATTEMPTS, + intervalMs: number = expectReleaseIntervalMs(), +): Promise<WaitForExpectedReleaseResult> { + const expectedTag = `${service}-${releaseVersion}`; + let tags: ReadonlyArray<string> = []; + const outcome = await boundedWait<true>( + async () => { + tags = await io.listReleaseTags(); + return tags.includes(expectedTag) ? { kind: "done", value: true } : { kind: "retry" }; + }, + io.wait, + attempts, + intervalMs, + ); + return { visible: outcome.status === "done", tags }; +} + +export interface CatalogRefreshResult { + readonly source: string; + readonly update?: CatalogPinUpdate; +} + +/** + * Refreshes one catalog entry: to exactly the committed release named by `release` (`<U>-r<N>`, + * required to already be committed — never "highest at apply time"), or to the highest committed + * revision of `upstream` (or, when both are omitted, of the entry's currently pinned upstream + * version). `upstream` and `release` are mutually exclusive. Pure aside from `io`: callers own + * reading and writing `Artifacts.ts`. + */ +export async function refreshCatalogPin(input: { + readonly catalog: string; + readonly service: string; + readonly upstream?: string; + readonly release?: string; + readonly io: RevisionIo; +}): Promise<CatalogRefreshResult> { + if (input.upstream !== undefined && input.release !== undefined) { + throw new InvalidPayloadError("--upstream and --release are mutually exclusive."); + } + if (input.upstream !== undefined && !UPSTREAM_VERSION_PATTERN.test(input.upstream)) { + throw new InvalidPayloadError(`invalid --upstream '${input.upstream}'`); + } + + let upstream = input.upstream; + let requiredRevision: number | undefined; + if (input.release !== undefined) { + if (!RELEASE_VERSION_PATTERN.test(input.release)) { + throw new InvalidPayloadError(`invalid --release '${input.release}'`); + } + const match = /^(.+)-r(0|[1-9][0-9]*)$/.exec(input.release) as RegExpExecArray; + upstream = match[1]; + requiredRevision = Number(match[2]); + } + + const entry = selectEntry(input.catalog, input.service, upstream); + if (entry.kind === "unmodelled-service") { + throw new InvalidPayloadError(`${CATALOG_PATH} has no slim entry for ${input.service}.`); + } + if (entry.kind === "unmodelled-release-line") { + throw new InvalidPayloadError( + `${input.service} ${upstream ?? ""} is not on a release line ${CATALOG_PATH} carries (${entry.known.join(", ")}).`, + ); + } + const resolvedUpstream = upstream ?? entry.version; + const resolution = await resolveRevisionPin( + input.service, + resolvedUpstream, + input.io, + requiredRevision, + ); + if (resolution.status !== "resolved") { + throw new InvalidPayloadError(resolution.message); + } + // Manual mode also backfills `upstreamImage`, so a plain `io` (most tests) can still exercise + // revision resolution without stubbing the extra fetchers. + const pin = + input.io.fetchManifest === undefined && input.io.fetchProvenance === undefined + ? resolution.pin + : { + ...resolution.pin, + upstreamImage: await resolveUpstreamImage( + input.service, + `${resolution.pin.upstreamVersion}-r${resolution.pin.revision}`, + resolvedUpstream, + input.io, + ), + }; + const written = writePin(input.catalog, entry, pin); + if (!written.changed) return { source: input.catalog }; + return { + source: written.source, + update: { + service: input.service, + version: resolvedUpstream, + revision: resolution.pin.revision, + previousVersion: entry.version, + target: entry.kind, + }, + }; +} + +function githubHeaders(): Record<string, string> { + const headers: Record<string, string> = { + Accept: "application/vnd.github+json", + "User-Agent": "supabase-cli-catalog-sync", + }; + const token = process.env.GITHUB_TOKEN; + if (token !== undefined && token !== "") headers.Authorization = `Bearer ${token}`; + return headers; +} -async function probeManifest(reference: string): Promise<{ probe: Probe; digest?: string }> { +/** Tags of every published, non-draft `supabase/slim-services` release. A draft is never a committed revision. */ +async function listReleaseTags(): Promise<ReadonlyArray<string>> { + const tags: string[] = []; + for (let page = 1; ; page++) { + const response = await fetch(`${RELEASES_API}?per_page=100&page=${page}`, { + headers: githubHeaders(), + }); + if (!response.ok) { + throw new InvalidPayloadError( + `slim-services releases list failed (HTTP ${response.status}).`, + ); + } + const batch: unknown = await response.json(); + if (!Array.isArray(batch)) { + throw new InvalidPayloadError("Malformed slim-services releases response."); + } + for (const item of batch) { + const record = item as { tag_name?: unknown; draft?: unknown } | null; + if (record?.draft === true) continue; + const tagName = record?.tag_name; + if (typeof tagName === "string") tags.push(tagName); + } + if (batch.length < 100) break; + } + return tags; +} + +async function fetchChecksums( + service: string, + releaseVersion: string, +): Promise<string | undefined> { + const response = await fetch(`${RELEASE_DOWNLOAD_BASE}/${service}-${releaseVersion}/SHA256SUMS`); + if (response.status === 404) return undefined; + if (!response.ok) { + throw new InvalidPayloadError( + `SHA256SUMS download failed for ${service}-${releaseVersion} (HTTP ${response.status}).`, + ); + } + return response.text(); +} + +async function imageDigest(service: string, releaseVersion: string): Promise<string | undefined> { + const reference = `${SLIM_IMAGE_PREFIX}${service}:${releaseVersion}`; const proc = Bun.spawn(["regctl", "manifest", "head", reference], { stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exit] = await Promise.all([ + const [stdout, , exit] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); const digest = stdout.trim(); - if (exit === 0 && DIGEST_PATTERN.test(digest)) return { probe: "published", digest }; - const detail = stderr.toLowerCase(); - if ( - detail.includes("manifest unknown") || - detail.includes("name unknown") || - detail.includes("not found") || - detail.includes("404") - ) { - return { probe: "missing" }; + return exit === 0 && DIGEST_PATTERN.test(digest) ? digest : undefined; +} + +async function s3Sha256(url: string): Promise<string | undefined> { + const response = await fetch(url); + if (!response.ok) return undefined; + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(new Uint8Array(await response.arrayBuffer())); + return hasher.digest("hex"); +} + +async function fetchManifest( + service: string, + releaseVersion: string, + target: NativeTargetName, +): Promise<string | undefined> { + const files = nativeFileNames(service, releaseVersion, target); + const response = await fetch( + `${RELEASE_DOWNLOAD_BASE}/${service}-${releaseVersion}/${files.manifest}`, + ); + if (response.status === 404) return undefined; + if (!response.ok) { + throw new InvalidPayloadError( + `manifest download failed for ${service}-${releaseVersion} ${target} (HTTP ${response.status}).`, + ); } - const message = stderr.trim(); - console.log( - `::warning ::${reference} publication check failed${message === "" ? "" : `: ${message}`}`, + return response.text(); +} + +async function fetchProvenance( + service: string, + releaseVersion: string, + upstreamVersion: string, +): Promise<string | undefined> { + const response = await fetch( + `${RELEASE_DOWNLOAD_BASE}/${service}-${releaseVersion}/${service}-${upstreamVersion}.oci-provenance.json`, ); - return { probe: "lookup-failed" }; + if (response.status === 404) return undefined; + if (!response.ok) { + throw new InvalidPayloadError( + `oci-provenance download failed for ${service}-${releaseVersion} (HTTP ${response.status}).`, + ); + } + return response.text(); } -async function probeNativeRelease(service: string, version: string): Promise<Probe> { - const tag = `${service}-${version}`; - const headers: Record<string, string> = { - Accept: "application/vnd.github+json", - "User-Agent": "supabase-cli-catalog-sync", - }; - const token = process.env.GITHUB_TOKEN; - if (token !== undefined && token !== "") headers.Authorization = `Bearer ${token}`; - try { - const response = await fetch(`${NATIVE_RELEASES}/${encodeURIComponent(tag)}`, { headers }); - if (response.status === 200) return "published"; - if (response.status === 404) return "missing"; - console.log(`::warning ::${tag} native release check returned HTTP ${response.status}.`); - return "lookup-failed"; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`::warning ::${tag} native release check failed: ${message}`); - return "lookup-failed"; - } -} - -async function lookupPublication(service: string, version: string): Promise<ReleasePublication> { - const reference = `${SLIM_IMAGE_PREFIX}${service}:${version}`; - const [manifest, native] = await Promise.all([ - probeManifest(reference), - probeNativeRelease(service, version), - ]); - if (manifest.probe === "lookup-failed" || native === "lookup-failed") { - return { status: "lookup-failed" }; +function defaultRevisionIo(): RevisionIo { + return { listReleaseTags, fetchChecksums, imageDigest, s3Sha256, fetchManifest, fetchProvenance }; +} + +function parseFlags(argv: ReadonlyArray<string>): ReadonlyMap<string, string> { + const flags = new Map<string, string>(); + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === undefined || !arg.startsWith("--")) { + throw new InvalidPayloadError(`unexpected argument '${arg ?? ""}'`); + } + const key = arg.slice(2); + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new InvalidPayloadError(`missing value for --${key}`); + } + flags.set(key, value); + index++; } - if (manifest.probe === "missing" || native === "missing") return { status: "missing" }; - return { status: "published", digest: manifest.digest }; + return flags; } -async function main(argv: ReadonlyArray<string>): Promise<void> { - const [dockerfilePath = DOCKERFILE_PATH, catalogPath = CATALOG_PATH, baseDockerfilePath] = argv; - const dockerfile = await Bun.file(dockerfilePath).text(); +async function runManual(argv: ReadonlyArray<string>): Promise<void> { + const flags = parseFlags(argv); + const service = flags.get("service"); + if (service === undefined) { + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts --service <service> [--upstream <U> | --release <U>-r<N>]", + ); + } + const catalogPath = CATALOG_PATH; const catalog = await Bun.file(catalogPath).text(); - const baseDockerfile = - baseDockerfilePath === undefined ? undefined : await Bun.file(baseDockerfilePath).text(); - const plan = await planArtifactCatalogUpdate({ - dockerfile, - baseDockerfile, + const result = await refreshCatalogPin({ catalog, - publication: lookupPublication, + service, + upstream: flags.get("upstream"), + release: flags.get("release"), + io: defaultRevisionIo(), }); - for (const skip of plan.skipped) { - console.log(`::warning ::Left ${skip.alias} unchanged: ${skip.reason}`); + if (result.update === undefined) { + console.log(`${service} already pins the highest committed revision.`); + return; } - if (plan.skipped.some((skip) => skip.blocking)) { - console.log("::error ::Refusing to commit a partial catalog update."); - process.exit(1); + await Bun.write(catalogPath, result.source); + console.log( + `Pinned ${service} ${result.update.target} ${result.update.previousVersion} -> ${result.update.version} r${result.update.revision}.`, + ); +} + +/** + * Line-oriented encoding of one `SlimUpdate`, for a bash loop: `kind`, `branch`, `title` and + * `release` (the loop's four required fields, driving the checkout/pin/commit/PR steps) plus + * `from` (the release replaced, for the PR body's "from -> to"). Every field is guaranteed + * non-empty by construction. `\x1f` (unit separator) is the delimiter, not a tab: a title can + * carry ordinary whitespace, and `IFS=$'\t' read` would collapse it. + */ +function updateLine(update: SlimUpdate): string { + return [update.kind, update.branch, update.title, update.toRelease, update.fromRelease].join( + "\x1f", + ); +} + +/** + * `plan-updates`' records go only into `--output <path>`, never stdout: a caller (the workflow) + * reads warnings from stdout as `::warning ::…` lines, and would otherwise mistake one for a + * malformed record and abort a run that had valid updates alongside it. + * + * `io.listReleaseTags` defaults to the real network lister but is overridable — the minimal seam + * a test uses to exercise this CLI mode's own file/stdout wiring (not just the pure planner) + * without a network call, the same pattern `RevisionIo` already uses. `io.wait` likewise defaults + * to a real delay but is overridable, the seam `waitForExpectedRelease`'s own unit tests use. The + * `--service`/`--output` usage check runs before either the catalog read or the lister, so a test + * can also assert this mode fails fast on a missing flag without touching the network. + * + * With `--expect-release <U>-r<N>` given, `<service>-<U>-r<N>` must be among the listed tags + * before planning runs at all — otherwise this dispatch's own release could be missing from a + * releases API that hasn't caught up yet, and the run would plan and pass quietly without it + * (`waitForExpectedRelease` handles the resulting eventual-consistency wait). Still missing after + * the bounded retries: this mode throws instead of planning, so `main()`'s handler reports an + * actionable `::error ::` and exits non-zero, and no `--output` file is written. + */ +export async function runPlanUpdates( + argv: ReadonlyArray<string>, + io: { + readonly listReleaseTags: () => Promise<ReadonlyArray<string>>; + readonly wait?: (ms: number) => Promise<void>; + } = { listReleaseTags }, +): Promise<void> { + const flags = parseFlags(argv); + const service = flags.get("service"); + const output = flags.get("output"); + const expectRelease = flags.get("expect-release"); + if (service === undefined || output === undefined) { + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts plan-updates --service <service> --output <path> " + + "[--format lines] [--expect-release <U>-r<N>]", + ); } - await Bun.write(catalogPath, plan.source); - if (plan.updates.length === 0) { - console.log("Workload catalog already matches the Dockerfile."); - return; + if (!SERVICE_NAME_PATTERN.test(service)) { + throw new InvalidPayloadError(`invalid --service ${JSON.stringify(service)}`); + } + const format = flags.get("format") ?? "json"; + if (format !== "json" && format !== "lines") { + throw new InvalidPayloadError(`invalid --format '${format}' (expected 'json' or 'lines')`); } - for (const update of plan.updates) { - console.log( - `Pinned ${update.service} ${update.target} ${update.previousVersion} -> ${update.version}.`, + if (expectRelease !== undefined && !RELEASE_VERSION_PATTERN.test(expectRelease)) { + throw new InvalidPayloadError(`invalid --expect-release ${JSON.stringify(expectRelease)}`); + } + + let listReleaseTags = io.listReleaseTags; + if (expectRelease !== undefined) { + const waited = await waitForExpectedRelease(service, expectRelease, { + listReleaseTags: io.listReleaseTags, + wait: io.wait ?? sleep, + }); + if (!waited.visible) { + throw new InvalidPayloadError( + `${service}-${expectRelease} is not visible yet from the slim-services releases API; re-run this workflow once it has propagated.`, + ); + } + // Reuses the listing the successful attempt already fetched, instead of listing again. + listReleaseTags = async () => waited.tags; + } + + const catalog = await Bun.file(CATALOG_PATH).text(); + const { updates, warnings } = await planUpdatesForService({ catalog, service, listReleaseTags }); + for (const warning of warnings) console.log(warning); + const content = + format === "lines" + ? updates.map((update) => `${updateLine(update)}\n`).join("") + : `${JSON.stringify(updates)}\n`; + await Bun.write(output, content); +} + +async function runValidatePayload(argv: ReadonlyArray<string>): Promise<void> { + const flags = parseFlags(argv); + const service = flags.get("service"); + const upstream = flags.get("upstream"); + const revision = flags.get("revision"); + const release = flags.get("release"); + if ( + service === undefined || + upstream === undefined || + revision === undefined || + release === undefined + ) { + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts validate-payload --service <service> --upstream <U> --revision <N> --release <R>", ); } + const payload = validateSlimReleasePublishedPayload({ + service, + upstream_version: upstream, + revision, + release_version: release, + }); + console.log(`service=${payload.service}`); + console.log(`upstream_version=${payload.upstream_version}`); + console.log(`revision=${payload.revision}`); + console.log(`release_version=${payload.release_version}`); +} + +async function main(argv: ReadonlyArray<string>): Promise<void> { + if (argv[0] === "validate-payload") { + await runValidatePayload(argv.slice(1)); + return; + } + if (argv[0] === "plan-updates") { + await runPlanUpdates(argv.slice(1)); + return; + } + if (argv[0]?.startsWith("--") === true) { + await runManual(argv); + return; + } + + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts --service <service> [--upstream <U> | --release <U>-r<N>], " + + "or validate-payload / plan-updates --service <service>", + ); } if (import.meta.main) { main(process.argv.slice(2)).catch((error: unknown) => { - console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); + console.log(workflowCommand("error", error instanceof Error ? error.message : String(error))); process.exit(1); }); } diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 3b8cb03676..dabc95430e 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -3,9 +3,10 @@ name: Mirror template images # Keeps the ghcr.io/ECR mirror in sync with the image versions pinned in # apps/cli/src/shared/services/Dockerfile, which `dockerfileServiceImages` # parses as the single source of truth for local service images. When the -# Dockerfile changes on develop — most often via a merged dependabot `docker` -# bump — this workflow detects any tag that is not yet mirrored and backfills -# it the same way `cli-go-mirror-image.yml` does. +# Dockerfile changes on develop — most often via a merged catalog bump or +# hotfix PR from slim-release-published.yml, or a Dependabot `docker` bump for +# an upstream-only image — this workflow detects any tag that is not yet +# mirrored and backfills it the same way `cli-go-mirror-image.yml` does. # # It runs on `push` to develop (not on the PR) on purpose: mirroring needs the # AWS role + packages:write, which a dependabot-triggered `pull_request` run diff --git a/.github/workflows/slim-release-published.yml b/.github/workflows/slim-release-published.yml new file mode 100644 index 0000000000..9e90d9a362 --- /dev/null +++ b/.github/workflows/slim-release-published.yml @@ -0,0 +1,227 @@ +name: Slim Release Published + +# supabase/slim-services sends this dispatch after it mints an immutable release +# `<service>-<upstream_version>-r<revision>`. The stack catalog +# (packages/stack/src/Artifacts.ts) is the single version table for the service; this workflow +# treats the dispatch as a trigger and reconciles the catalog against every committed +# (published, non-draft) `<service>-...-r<N>` release, opening or updating one pull request per +# hotfix and/or upgrade it finds (`.github/scripts/sync-artifacts-catalog.ts`'s `planSlimUpdates`). +# +# Plan and apply run from the same checkout of the default branch, in a single job: a re-run +# always recomputes from the latest develop, so a stale plan can never be applied, and a +# superseded PR's branch is rewritten in place (force-pushed) instead of racing a fresh one. A +# backlog republish of an older upstream version naturally plans nothing. +# +# The payload arrives with whatever authority holds the dispatch token, so it is treated as +# untrusted: fields are pattern- and shape-checked before use +# (`validateSlimReleasePublishedPayload`), and never interpolated directly into a `run:` script +# (only passed through `env:`). Release tag names come from an external API too, so every value +# `planSlimUpdates` emits is re-validated against the same anchored patterns before it can reach a +# branch name or PR title. + +on: + repository_dispatch: + types: + - slim-release-published + +permissions: + contents: read + +concurrency: + group: slim-release-published-${{ github.event.client_payload.service }} + cancel-in-progress: false + +jobs: + pickup: + runs-on: ubuntu-latest + # Setup (~10 min) + release visibility (<1 min) + the S3-mirror wait (up to ~10 min, normally only + # for the first planned item) + pin, render and push per item. + timeout-minutes: 45 + permissions: + contents: read + steps: + - name: Checkout the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + # Installs the locked workspace dependencies the scripts need: `Artifacts.ts` imports + # `effect`, and `render-service-dockerfile.ts` imports `@supabase/stack/internal/artifacts`. + # Replaces the bare mise step the pre-single-table version of this workflow used. + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Record the base commit + id: base + run: echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + + # Delegates to `validateSlimReleasePublishedPayload` (sync-artifacts-catalog.ts) so every + # field — including `upstream_version` and `release_version`, not just `service` and + # `revision` — is checked against an anchored charset before it is ever written to + # `$GITHUB_OUTPUT`, a branch name, or a PR title. A value that fails validation makes the + # script exit non-zero and print a single `::error ::…` line to stdout (the rejected value + # is JSON-escaped in that message, so it can never smuggle in a newline or workflow command), + # which this step re-echoes so it still surfaces as an annotation. + - name: Validate payload + id: validate + env: + SERVICE: ${{ github.event.client_payload.service }} + UPSTREAM_VERSION: ${{ github.event.client_payload.upstream_version }} + REVISION: ${{ github.event.client_payload.revision }} + RELEASE_VERSION: ${{ github.event.client_payload.release_version }} + run: | + set -euo pipefail + if ! output="$(bun .github/scripts/sync-artifacts-catalog.ts validate-payload \ + --service "$SERVICE" --upstream "$UPSTREAM_VERSION" --revision "$REVISION" --release "$RELEASE_VERSION")"; then + echo "$output" + exit 1 + fi + echo "$output" >>"$GITHUB_OUTPUT" + + # Reconciles the service against every committed slim-services release (`planSlimUpdates`), + # not just the release that triggered this dispatch — this run is idempotent and safe to + # receive out of order. Records go only to `--output` (never stdout): each line is + # field-separated with `\x1f` (see `updateLine`), which a later step reads directly. Any + # `::warning ::…` the planner emits (an ignored tag, say) prints to this step's own stdout + # instead, so it can never be mistaken for a malformed record. + # + # `--expect-release` guards against the releases API lagging behind this very dispatch: it + # requires `<service>-<release_version>` to be a listed tag before planning runs at all, + # re-listing a bounded number of times first (`waitForExpectedRelease`). Without it, a slow + # API would make this run plan without the release that triggered it, and pass quietly. + - name: Plan updates + id: plan + env: + SERVICE: ${{ steps.validate.outputs.service }} + RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + bun .github/scripts/sync-artifacts-catalog.ts plan-updates --service "$SERVICE" --format lines \ + --expect-release "$RELEASE_VERSION" --output "${RUNNER_TEMP}/slim-updates.tsv" + count="$(wc -l < "${RUNNER_TEMP}/slim-updates.tsv" | tr -d ' ')" + echo "count=${count}" >>"$GITHUB_OUTPUT" + + - name: Nothing to do + if: steps.plan.outputs.count == '0' + env: + SERVICE: ${{ steps.validate.outputs.service }} + RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + run: echo "No catalog updates for ${SERVICE} from ${RELEASE_VERSION}; nothing to do." + + - name: Install regctl + if: steps.plan.outputs.count != '0' + run: | + set -euo pipefail + install -d "${RUNNER_TEMP}/regctl-bin" + curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ + https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 + echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" + echo "${RUNNER_TEMP}/regctl-bin" >>"$GITHUB_PATH" + "${RUNNER_TEMP}/regctl-bin/regctl" version + + - name: Generate token + if: steps.plan.outputs.count != '0' + 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 }} + permission-contents: write + permission-pull-requests: write + + # One branch per planned item, built fresh from the base commit recorded above (the default + # branch's head at the start of this run) — never from wherever a previous loop iteration + # left HEAD. Pins exactly the planned release (`--release`, never "highest at apply time"), + # so plan and pin agree by construction: a revision minted a second later arrives with its + # own dispatch, not by racing this one. + # + # The app token (contents + pull-requests write) never reaches third-party code or disk: + # `git push` takes it only as part of an explicit URL, computed once as `push_url` + # (overridable via `PUSH_REMOTE_URL`, the seam a dry run uses to target a local bare repo + # instead), and `gh` gets it inline per call. Every `bun`/`pnpm` command below — the sync + # script, the Dockerfile generator, the formatter — runs under `env -u APP_TOKEN` so a + # compromised transitive dependency of any of them (they import `effect`) can't read it. + - name: Apply planned updates + if: steps.plan.outputs.count != '0' + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} + SERVICE: ${{ steps.validate.outputs.service }} + RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + BASE_SHA: ${{ steps.base.outputs.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + push_url="${PUSH_REMOTE_URL:-https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}" + manual_prefix="bun .github/scripts/sync-artifacts-catalog.ts --service ${SERVICE} --release" + + # Read the records on fd 3, not stdin: every command the loop body runs (`bun`, `gh`, + # `git`) otherwise shares stdin with the `read`, and any of them consuming a byte of it + # would swallow the rest of the file's records. + while IFS=$'\x1f' read -r -u 3 kind branch title release from; do + if [ -z "$kind" ] || [ -z "$branch" ] || [ -z "$title" ] || [ -z "$release" ] || [ -z "$from" ]; then + echo "::error ::Malformed plan-updates line: kind='${kind}' branch='${branch}' title='${title}' release='${release}' from='${from}'." + exit 1 + fi + + git checkout -B "$branch" "$BASE_SHA" + + env -u APP_TOKEN bun .github/scripts/sync-artifacts-catalog.ts --service "$SERVICE" --release "$release" + env -u APP_TOKEN -u GITHUB_TOKEN pnpm exec oxfmt --config .oxfmtrc.json packages/stack/src/Artifacts.ts + env -u APP_TOKEN -u GITHUB_TOKEN bun apps/cli/scripts/render-service-dockerfile.ts + + generated_files=( + packages/stack/src/Artifacts.ts + apps/cli/src/shared/services/Dockerfile + apps/cli-go/pkg/config/templates/Dockerfile + ) + if git diff --quiet -- "${generated_files[@]}"; then + echo "${branch}: catalog and Dockerfiles already match ${release}; skipping." + continue + fi + + git add -- "${generated_files[@]}" + git commit -m "$title" + + if ! git push --force "$push_url" "HEAD:refs/heads/${branch}"; then + echo "::error ::Failed to push ${branch}. Run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." + exit 1 + fi + + if [ "$kind" = "upgrade" ]; then + action="bumps" + else + action="pins" + fi + # Names the release this PR actually pins as the subject; the dispatch's triggering + # release (RELEASE_VERSION) can differ from it (a hotfix dispatch commonly also + # yields an unrelated upgrade on the same line), so it's only mentioned, not implied + # to be what changed. + body="$(printf 'This %s %s from %s to %s.\n\nhttps://github.com/supabase/slim-services/releases/tag/%s-%s\n\nPlanned after supabase/slim-services published %s. This branch is rewritten from %s whenever a newer relevant release arrives.\n' \ + "$action" "$SERVICE" "$from" "$release" "$SERVICE" "$release" "$RELEASE_VERSION" "$DEFAULT_BRANCH")" + + # `--head` matches by branch name only and ignores the owner, so a fork PR with the + # same head branch name would otherwise match too; `isCrossRepository` excludes it. + existing="$(GH_TOKEN="$APP_TOKEN" gh pr list --head "$branch" --base "$DEFAULT_BRANCH" --state open --json number,isCrossRepository --jq 'map(select(.isCrossRepository | not)) | .[0].number // empty')" + if [ -n "$existing" ]; then + if ! GH_TOKEN="$APP_TOKEN" gh pr edit "$existing" --title "$title" --body "$body"; then + echo "::error ::Pushed ${branch} but failed to edit pull request #${existing}. Run manually: ${manual_prefix} ${release}, then edit the pull request from ${branch} by hand." + exit 1 + fi + elif ! GH_TOKEN="$APP_TOKEN" gh pr create \ + --title "$title" \ + --body "$body" \ + --assignee jgoux \ + --head "$branch" \ + --base "$DEFAULT_BRANCH"; then + echo "::error ::Pushed ${branch} but failed to open a pull request. Run manually: ${manual_prefix} ${release}, then open a pull request from ${branch} against ${DEFAULT_BRANCH} by hand." + exit 1 + fi + done 3< "${RUNNER_TEMP}/slim-updates.tsv" diff --git a/.github/workflows/sync-artifacts-catalog.yml b/.github/workflows/sync-artifacts-catalog.yml deleted file mode 100644 index d57b5d7b28..0000000000 --- a/.github/workflows/sync-artifacts-catalog.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Sync Artifacts catalog - -# The script is the base revision. The pull request only supplies the Dockerfile -# and catalog text, and the job runs for Dependabot so that pull request cannot -# edit this workflow and then execute it with a write token. The catalog commit -# is pushed with the app token so the new head triggers CI. Dependabot's -# GITHUB_TOKEN would not. -on: - pull_request: - paths: - - apps/cli/src/shared/services/Dockerfile - -permissions: - contents: read - -concurrency: - group: sync-artifacts-catalog-${{ github.event.pull_request.number }} - cancel-in-progress: false - -jobs: - sync: - name: Pin workload catalog - if: github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - persist-credentials: true - fetch-depth: 1 - - # Bun only. The script imports no workspace packages. The firewall token - # is empty on a Dependabot run; setup is not required to format one file. - - name: Install toolchains - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - with: - version: 2026.9.0 - - - name: Install regctl - run: | - set -euo pipefail - install -d "${RUNNER_TEMP}/regctl-bin" - curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ - https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 - echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - - chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" - echo "${RUNNER_TEMP}/regctl-bin" >> "$GITHUB_PATH" - "${RUNNER_TEMP}/regctl-bin/regctl" version - - - name: Generate token - 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 }} - permission-contents: write - - - name: Pin the catalog to the Dockerfile - env: - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git fetch --no-tags origin "pull/${PR_NUMBER}/head" - fetched="$(git rev-parse FETCH_HEAD)" - if [ "$fetched" != "$HEAD_SHA" ]; then - echo "::error ::pull/${PR_NUMBER} head is ${fetched}, expected ${HEAD_SHA}." - exit 1 - fi - dockerfile="$(mktemp)" - catalog="$(mktemp)" - original="$(mktemp)" - git show "$HEAD_SHA:apps/cli/src/shared/services/Dockerfile" > "$dockerfile" - git show "$HEAD_SHA:packages/stack/src/Artifacts.ts" > "$catalog" - cp "$catalog" "$original" - bun .github/scripts/sync-artifacts-catalog.ts "$dockerfile" "$catalog" apps/cli/src/shared/services/Dockerfile - if cmp -s "$original" "$catalog"; then - echo "Workload catalog already matches the Dockerfile." - exit 0 - fi - git checkout --detach "$HEAD_SHA" - cp "$catalog" packages/stack/src/Artifacts.ts - bun x oxfmt@0.65.0 --config .oxfmtrc.json packages/stack/src/Artifacts.ts - git add -- packages/stack/src/Artifacts.ts - if git diff --cached --quiet -- packages/stack/src/Artifacts.ts; then - echo "Workload catalog already matches the Dockerfile." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore(stack): pin the workload catalog to the Dockerfile image tags" - git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git push origin "HEAD:refs/heads/${HEAD_REF}" diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 0491495db9..5c2eca797e 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -883,11 +883,11 @@ func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error // Update image versions switch c.Db.MajorVersion { case 13: - c.Db.Image = pg15 + c.Db.Image = Images.Pg15 case 14: - c.Db.Image = pg14 + c.Db.Image = Images.Pg14 case 15: - c.Db.Image = pg15 + c.Db.Image = Images.Pg15 } if c.Db.MajorVersion > 14 { if version, err := fs.ReadFile(fsys, builder.PostgresVersionPath); err == nil { diff --git a/apps/cli-go/pkg/config/constants.go b/apps/cli-go/pkg/config/constants.go index 08d572d2da..93bb482f69 100644 --- a/apps/cli-go/pkg/config/constants.go +++ b/apps/cli-go/pkg/config/constants.go @@ -9,14 +9,15 @@ import ( ) const ( - pg13 = "supabase/postgres:13.3.0" - pg14 = "supabase/postgres:14.1.0.89" - pg15 = "supabase/postgres:15.8.1.085" deno1 = "supabase/edge-runtime:v1.68.4" ) type images struct { Pg string `mapstructure:"pg"` + // PG13/15 and PG14 come from the Dockerfile's generated `pg15`/hand-pinned `pg14` stages + // (the single version table), not hardcoded constants. + Pg15 string `mapstructure:"pg15"` + Pg14 string `mapstructure:"pg14"` // Append to Services when adding new dependencies below Kong string `mapstructure:"kong"` Inbucket string `mapstructure:"mailpit"` diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index a9c85ee0ca..aee8c87f83 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,21 +1,29 @@ -# Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.171 AS pg +# The tag pinned below is generated from packages/stack/src/Artifacts.ts by +# apps/cli/scripts/render-service-dockerfile.ts, for these aliases only: pg, pg15, mailpit, +# postgrest, pgmeta, studio, imgproxy, edgeruntime, vector, supavisor, gotrue, realtime, storage, +# logflare. Do not hand-edit those tags; a CI drift check enforces this. Run the generator after +# a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job +# images) is hand- or Dependabot-managed. +FROM supabase/postgres:17.11.0.002 AS pg +FROM supabase/postgres:15.19.0.002 AS pg15 +# Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). +FROM supabase/postgres:14.1.0.89 AS pg14 # New always-on service alias: extend SERVICE_IMAGE_ALIASES in # shared/services/services.shared.ts and DOCKERFILE_ALIAS_BY_SERVICE in # commands/start/start.gates.ts. FROM library/kong:2.8.1 AS kong -FROM axllent/mailpit:v1.30.2 AS mailpit -FROM postgrest/postgrest:v16.3 AS postgrest +FROM axllent/mailpit:v1.31.3 AS mailpit +FROM postgrest/postgrest:v16.4 AS postgrest FROM supabase/postgres-meta:v0.99.0 AS pgmeta -FROM supabase/studio:2026.09.14-sha-4dd8a95 AS studio -FROM darthsim/imgproxy:v3.8.0 AS imgproxy +FROM supabase/studio:2026.09.28-sha-5e59b60 AS studio +FROM darthsim/imgproxy:v3.26.0 AS imgproxy FROM supabase/edge-runtime:v1.77.1 AS edgeruntime -FROM timberio/vector:0.53.0-alpine AS vector +FROM timberio/vector:0.58.0-alpine AS vector FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.135.3 AS realtime -FROM supabase/storage-api:v1.77.0 AS storage -FROM supabase/logflare:1.50.12 AS logflare +FROM supabase/realtime:v2.140.3 AS realtime +FROM supabase/storage-api:v1.79.28 AS storage +FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts new file mode 100644 index 0000000000..73080b1628 --- /dev/null +++ b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test, vi } from "vitest"; + +import { renderDockerfile } from "./render-service-dockerfile.ts"; + +// A minimal fixture catalog covering every slim-capable alias, so `renderDockerfile`'s +// "every alias needs exactly one line" check is satisfied by default; each test only mutates +// what it's exercising. `vi.mock` factories are hoisted above every other top-level statement, +// so the fixture pins are built inline here rather than imported from an outer module. +vi.mock("@supabase/stack/internal/artifacts", () => { + const nativePin = { archive: "a".repeat(64), manifest: "b".repeat(64) }; + const natives = { "darwin-arm64": nativePin, "linux-amd64": nativePin, "linux-arm64": nativePin }; + const pin = (sourceService: string, upstreamImage: string, isDefault: boolean) => ({ + service: sourceService, + sourceService, + isDefault, + pin: { + upstreamVersion: upstreamImage.split(":").at(-1), + revision: 0, + image: `ghcr.io/supabase/cli/${sourceService}:fixture-r0@sha256:${"c".repeat(64)}`, + upstreamImage, + natives, + }, + }); + return { + catalogPins: () => [ + pin("postgres", "supabase/postgres:17.0.0", true), + pin("postgres", "supabase/postgres:15.0.0", false), + pin("mailpit", "axllent/mailpit:v1.0.0", true), + pin("postgrest", "postgrest/postgrest:v1.0.0", true), + pin("pgmeta", "supabase/postgres-meta:v1.0.0", true), + pin("studio", "supabase/studio:1.0.0", true), + pin("imgproxy", "ghcr.io/imgproxy/imgproxy:v1.0.0", true), + pin("edge-runtime", "supabase/edge-runtime:v1.0.0", true), + pin("vector", "timberio/vector:1.0.0-alpine", true), + pin("pooler", "supabase/supavisor:1.0.0", true), + pin("auth", "supabase/gotrue:v1.0.0", true), + pin("realtime", "supabase/realtime:v1.0.0", true), + pin("storage", "supabase/storage-api:v1.0.0", true), + pin("analytics", "supabase/logflare:1.0.0", true), + ], + }; +}); + +/** A minimal Dockerfile with every slim-capable alias, plus kong and pg14 (hand-managed). */ +function baseDockerfile(overrides: Readonly<Record<string, string>> = {}): string { + const lines: Readonly<Record<string, string>> = { + pg: "FROM supabase/postgres:16.9.9 AS pg", + pg15: "FROM supabase/postgres:14.9.9 AS pg15", + mailpit: "FROM axllent/mailpit:v0.9.9 AS mailpit", + postgrest: "FROM postgrest/postgrest:v0.9.9 AS postgrest", + pgmeta: "FROM supabase/postgres-meta:v0.9.9 AS pgmeta", + studio: "FROM supabase/studio:0.9.9 AS studio", + imgproxy: "FROM darthsim/imgproxy:v0.9.9 AS imgproxy", + edgeruntime: "FROM supabase/edge-runtime:v0.9.9 AS edgeruntime", + vector: "FROM timberio/vector:0.9.9-alpine AS vector", + supavisor: "FROM supabase/supavisor:0.9.9 AS supavisor", + gotrue: "FROM supabase/gotrue:v0.9.9 AS gotrue", + realtime: "FROM supabase/realtime:v0.9.9 AS realtime", + storage: "FROM supabase/storage-api:v0.9.9 AS storage", + logflare: "FROM supabase/logflare:0.9.9 AS logflare", + kong: "FROM library/kong:2.8.1 AS kong", + }; + const merged = { ...lines, ...overrides }; + return `# hand-authored header\n${Object.values(merged).join("\n")}\n# hand-authored footer\n`; +} + +describe("renderDockerfile: pin selection", () => { + test("pg takes the default pin and pg15 takes the additional one, by isDefault, not a version-string check", () => { + const rendered = renderDockerfile(baseDockerfile()); + expect(rendered).toContain("FROM supabase/postgres:17.0.0 AS pg\n"); + expect(rendered).toContain("FROM supabase/postgres:15.0.0 AS pg15\n"); + }); +}); + +describe("renderDockerfile: repository rule", () => { + test("keeps the existing repository and only replaces the tag", () => { + const rendered = renderDockerfile(baseDockerfile()); + expect(rendered).toContain("FROM supabase/gotrue:v1.0.0 AS gotrue\n"); + }); + + test("fails loudly when the Dockerfile's repository no longer matches the catalog's, for a non-overridden alias", () => { + expect(() => + renderDockerfile(baseDockerfile({ gotrue: "FROM some-other-org/gotrue:v0.9.9 AS gotrue" })), + ).toThrow(/gotrue.*repository/i); + }); + + test("tolerates imgproxy's documented repository mismatch (the allowlisted override)", () => { + const rendered = renderDockerfile(baseDockerfile()); + // Catalog upstreamImage repo is ghcr.io/imgproxy/imgproxy; the Dockerfile keeps darthsim. + expect(rendered).toContain("FROM darthsim/imgproxy:v1.0.0 AS imgproxy\n"); + }); + + test("still fails loudly when imgproxy's Dockerfile repository drifts from its override entry", () => { + expect(() => + renderDockerfile( + baseDockerfile({ imgproxy: "FROM some-other-org/imgproxy:v0.9.9 AS imgproxy" }), + ), + ).toThrow(/imgproxy.*REPOSITORY_OVERRIDES/i); + }); +}); + +describe("renderDockerfile: line preservation", () => { + test("never inserts a line for a missing slim-capable alias", () => { + const withoutPg15 = baseDockerfile().replace(/FROM supabase\/postgres:14\.9\.9 AS pg15\n/, ""); + expect(() => renderDockerfile(withoutPg15)).toThrow(/pg15/); + }); + + test("leaves non-slim-capable lines (kong) and comments byte-for-byte untouched", () => { + const current = baseDockerfile(); + const rendered = renderDockerfile(current); + expect(rendered).toContain("FROM library/kong:2.8.1 AS kong"); + expect(rendered).toContain("# hand-authored header"); + expect(rendered).toContain("# hand-authored footer"); + }); +}); diff --git a/apps/cli/scripts/render-service-dockerfile.ts b/apps/cli/scripts/render-service-dockerfile.ts new file mode 100644 index 0000000000..6c6c114eb6 --- /dev/null +++ b/apps/cli/scripts/render-service-dockerfile.ts @@ -0,0 +1,192 @@ +// Rewrites the slim-capable `FROM ... AS <alias>` lines of +// apps/cli/src/shared/services/Dockerfile (and its byte-identical Go copy, +// apps/cli-go/pkg/config/templates/Dockerfile) in place, from the stack catalog +// (packages/stack/src/Artifacts.ts): each such line's tag comes from that service's catalog pin +// `upstreamImage`. Every other line — comments, kong, the Postgres 14 pin (no slim build), and +// the one-shot job images — is hand- or Dependabot-managed and left byte-for-byte untouched. +// +// bun apps/cli/scripts/render-service-dockerfile.ts # writes both files +// bun apps/cli/scripts/render-service-dockerfile.ts --check # fails on drift, writes nothing +// +// The `--check` mode is what CI runs (as a `render-service-dockerfile.unit.test.ts` assertion) to +// catch a hand-edited generated line, or a catalog change that hasn't been regenerated yet. +import { fileURLToPath } from "node:url"; +import { catalogPins, type ArtifactPin } from "@supabase/stack/internal/artifacts"; + +// Resolved from this module's own URL, so both the CLI invocation (cwd = repo root) and the +// vitest unit test (cwd = apps/cli) read the same files. +export const TS_DOCKERFILE_PATH = fileURLToPath( + new URL("../src/shared/services/Dockerfile", import.meta.url), +); +export const GO_DOCKERFILE_PATH = fileURLToPath( + new URL("../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), +); + +type CatalogEntry = ReturnType<typeof catalogPins>[number]; + +/** + * Dockerfile repository overrides, for the one documented exception to the repository rule + * below. Keep this list to that exception; add a reason alongside any addition. + */ +const REPOSITORY_OVERRIDES: Readonly<Record<string, string>> = { + // slim-services mirrors ghcr.io/imgproxy/imgproxy (its `upstreamImage`), but the Dockerfile — + // and `mirror-template-images.yml` and the docker.io registry rewrite — keep darthsim/imgproxy, + // which carries the same tags on Docker Hub. + imgproxy: "darthsim/imgproxy", +}; + +interface SlimAliasSpec { + readonly sourceService: string; + /** Selects the catalog's default pin (every alias but `pg15`) or its lone additional pin. */ + readonly wantDefault: boolean; +} + +/** Every slim-capable Dockerfile alias, mapped to the catalog pin it tracks. */ +const SLIM_ALIAS_SPECS: ReadonlyMap<string, SlimAliasSpec> = new Map([ + ["pg", { sourceService: "postgres", wantDefault: true }], + ["pg15", { sourceService: "postgres", wantDefault: false }], + ["mailpit", { sourceService: "mailpit", wantDefault: true }], + ["postgrest", { sourceService: "postgrest", wantDefault: true }], + ["pgmeta", { sourceService: "pgmeta", wantDefault: true }], + ["studio", { sourceService: "studio", wantDefault: true }], + ["imgproxy", { sourceService: "imgproxy", wantDefault: true }], + ["edgeruntime", { sourceService: "edge-runtime", wantDefault: true }], + ["vector", { sourceService: "vector", wantDefault: true }], + ["supavisor", { sourceService: "pooler", wantDefault: true }], + ["gotrue", { sourceService: "auth", wantDefault: true }], + ["realtime", { sourceService: "realtime", wantDefault: true }], + ["storage", { sourceService: "storage", wantDefault: true }], + ["logflare", { sourceService: "analytics", wantDefault: true }], +]); + +function selectPin( + alias: string, + spec: SlimAliasSpec, + pins: ReadonlyArray<CatalogEntry>, +): ArtifactPin { + const candidates = pins.filter( + (entry) => entry.sourceService === spec.sourceService && entry.isDefault === spec.wantDefault, + ); + if (candidates.length !== 1) { + throw new Error( + `expected exactly one ${spec.wantDefault ? "default" : "additional"} catalog pin for ` + + `'${spec.sourceService}' (alias '${alias}'), found ${candidates.length}`, + ); + } + return candidates[0]!.pin; +} + +function tagOf(upstreamImage: string): string { + const sep = upstreamImage.lastIndexOf(":"); + if (sep === -1) throw new Error(`upstreamImage has no tag: ${upstreamImage}`); + return upstreamImage.slice(sep + 1); +} + +function repositoryOf(upstreamImage: string): string { + const sep = upstreamImage.lastIndexOf(":"); + if (sep === -1) throw new Error(`upstreamImage has no tag: ${upstreamImage}`); + return upstreamImage.slice(0, sep); +} + +/** Matches a `FROM <repository>:<tag> AS <alias>` line, keeping every other byte for reuse. */ +const FROM_LINE = /^(FROM\s+)(.+):([^:\s]+)(\s+AS\s+)([^\s#]+)(.*)$/i; + +/** + * Rewrites only the slim-capable `FROM ... AS <alias>` lines of `currentDockerfile`, in place: + * the repository is read from the existing line (and asserted to still match the catalog pin's + * `upstreamImage` repository, except for `REPOSITORY_OVERRIDES`); only the tag is replaced, from + * that pin's `upstreamImage`. Every other line — comments, kong, `pg14`, the job images — passes + * through untouched. A slim-capable alias missing from the file is an error; the generator never + * inserts a line. + */ +export function renderDockerfile(currentDockerfile: string): string { + const pins = catalogPins(); + const seen = new Set<string>(); + + const lines = currentDockerfile.split("\n").map((line) => { + const match = FROM_LINE.exec(line); + if (match === null) return line; + const [, fromKeyword, repository, , asKeyword, alias, rest] = match as unknown as [ + string, + string, + string, + string, + string, + string, + string, + ]; + const spec = SLIM_ALIAS_SPECS.get(alias); + if (spec === undefined) return line; + seen.add(alias); + + const pin = selectPin(alias, spec, pins); + const pinRepository = repositoryOf(pin.upstreamImage); + const override = REPOSITORY_OVERRIDES[alias]; + if (override === undefined) { + if (repository !== pinRepository) { + throw new Error( + `${alias}'s Dockerfile repository '${repository}' no longer matches the catalog's ` + + `'${pinRepository}'. If this is intentional, add '${alias}' to REPOSITORY_OVERRIDES with a reason.`, + ); + } + } else if (repository !== override) { + throw new Error( + `${alias}'s Dockerfile repository '${repository}' no longer matches its ` + + `REPOSITORY_OVERRIDES entry '${override}'. Update the override if this is intentional.`, + ); + } + + return `${fromKeyword}${repository}:${tagOf(pin.upstreamImage)}${asKeyword}${alias}${rest}`; + }); + + for (const alias of SLIM_ALIAS_SPECS.keys()) { + if (!seen.has(alias)) { + throw new Error( + `no Dockerfile line for slim-capable alias '${alias}'; the generator never inserts one`, + ); + } + } + + return lines.join("\n"); +} + +async function main(argv: ReadonlyArray<string>): Promise<void> { + const check = argv.includes("--check"); + const current = await Bun.file(TS_DOCKERFILE_PATH).text(); + const rendered = renderDockerfile(current); + + if (check) { + const go = await Bun.file(GO_DOCKERFILE_PATH).text(); + let failed = false; + if (current !== rendered) { + console.log( + `::error ::${TS_DOCKERFILE_PATH} has drifted from packages/stack/src/Artifacts.ts. Run ` + + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", + ); + failed = true; + } + if (go !== rendered) { + console.log( + `::error ::${GO_DOCKERFILE_PATH} is not a byte copy of ${TS_DOCKERFILE_PATH}. Run ` + + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", + ); + failed = true; + } + if (failed) { + process.exit(1); + } + console.log("Both Dockerfiles match the catalog."); + return; + } + + await Bun.write(TS_DOCKERFILE_PATH, rendered); + await Bun.write(GO_DOCKERFILE_PATH, rendered); + console.log(`Regenerated ${TS_DOCKERFILE_PATH} and ${GO_DOCKERFILE_PATH} from the catalog.`); +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((error: unknown) => { + console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/apps/cli/scripts/render-service-dockerfile.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.unit.test.ts new file mode 100644 index 0000000000..0c4f9036d9 --- /dev/null +++ b/apps/cli/scripts/render-service-dockerfile.unit.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +import { + GO_DOCKERFILE_PATH, + TS_DOCKERFILE_PATH, + renderDockerfile, +} from "./render-service-dockerfile.ts"; + +// Both against the real, checked-in Dockerfile and the real catalog — no hardcoded version +// literal, so a catalog bump never makes this drift-detection test itself go stale. +const currentTsDockerfile = readFileSync(TS_DOCKERFILE_PATH, "utf8"); + +describe("renderDockerfile against the real catalog and Dockerfile", () => { + test("round-trips: rewriting the checked-in Dockerfile reproduces it byte for byte (no drift)", () => { + expect(renderDockerfile(currentTsDockerfile)).toBe(currentTsDockerfile); + }); + + test("rendering twice is stable", () => { + expect(renderDockerfile(currentTsDockerfile)).toBe(renderDockerfile(currentTsDockerfile)); + }); + + // The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed yet; + // this is the single place that keeps the two copies in sync (folded from the former + // dockerfile-go-sync.unit.test.ts), until apps/cli-go is deleted. + test("the Go tree's embedded Dockerfile is a byte copy of the TS-owned one", () => { + const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); + expect(goDockerfile).toBe(currentTsDockerfile); + }); + + test("detects drift when a generated line is hand-edited", () => { + // A hand-edit to a generated tag (bumping gotrue without touching the catalog) must not + // round-trip back to itself — this is the drift check's whole point. + const tampered = currentTsDockerfile.replace( + /FROM supabase\/gotrue:v[^\s]+ AS gotrue/, + "FROM supabase/gotrue:v9.9.9 AS gotrue", + ); + expect(tampered).not.toBe(currentTsDockerfile); + expect(renderDockerfile(tampered)).not.toBe(tampered); + // It heals back to the catalog-pinned tag, not just "detects a difference". + expect(renderDockerfile(tampered)).toBe(currentTsDockerfile); + }); + + test("preserves hand-/Dependabot-managed lines verbatim, even when their own tags change", () => { + // Simulates a Dependabot bump of kong/differ/migra/pgprove: give each a synthetic marker + // tag (never a real pin) and confirm the generator leaves those exact lines alone rather + // than reverting or otherwise touching them. + const marker = "99.99.99-fixture-marker"; + let tampered = currentTsDockerfile; + for (const alias of ["kong", "differ", "migra", "pgprove"]) { + const pattern = new RegExp(`^FROM (\\S+):(\\S+) AS ${alias}$`, "m"); + const match = pattern.exec(tampered); + expect(match, `no line for hand-managed alias '${alias}'`).not.toBeNull(); + tampered = tampered.replace(pattern, `FROM $1:${marker} AS ${alias}`); + } + expect(tampered).not.toBe(currentTsDockerfile); + + const rendered = renderDockerfile(tampered); + for (const alias of ["kong", "differ", "migra", "pgprove"]) { + const tamperedLine = tampered.split("\n").find((line) => line.endsWith(`AS ${alias}`)); + expect(tamperedLine).toBeDefined(); + expect(tamperedLine).toContain(marker); + expect(rendered).toContain(tamperedLine); + } + }); +}); diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts index ac124dce97..faaaac4c2b 100644 --- a/apps/cli/scripts/sweep-live-projects.integration.test.ts +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -6,6 +6,13 @@ import path from "node:path"; const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); const directories: string[] = []; +// Per-sweep hang guard: a spawned run gets this long before the safety net kills +// it. A test's own timeout must cover every sweep it runs plus report margin, so +// derive it from the sweep count rather than a single shared budget. +const SWEEP_GUARD_MS = 30_000; +const REPORT_MARGIN_MS = 10_000; +const sweepBudget = (sweeps: number) => sweeps * SWEEP_GUARD_MS + REPORT_MARGIN_MS; + afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); }); @@ -73,13 +80,22 @@ async function runSweep(scenario: Scenario) { stdout: "pipe", stderr: "pipe", }); - const timeout = setTimeout(() => child.kill(), 10_000); + let hung = false; + const timeout = setTimeout(() => { + hung = true; + child.kill(); + }, SWEEP_GUARD_MS); const [exitCode, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); clearTimeout(timeout); + if (hung) { + throw new Error( + `sweep script hung: killed after ${SWEEP_GUARD_MS}ms without exiting (stdout: ${stdout}, stderr: ${stderr})`, + ); + } return { exitCode, stdout, stderr, deletes }; } finally { await server.stop(true); @@ -88,75 +104,91 @@ async function runSweep(scenario: Scenario) { const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); -describe.skipIf(process.platform === "win32")("sweep-live-projects.sh", { timeout: 40_000 }, () => { - test("accepts refused deletion when a fresh authenticated list shows absence", async () => { - const result = await runSweep({ - lists: [[active("gone")], []], - deletes: { gone: { status: 403, body: { message: "already removed" } } }, +describe.skipIf(process.platform === "win32")( + "sweep-live-projects.sh", + { timeout: sweepBudget(1) }, + () => { + test("accepts refused deletion when a fresh authenticated list shows absence", async () => { + const result = await runSweep({ + lists: [[active("gone")], []], + deletes: { gone: { status: 403, body: { message: "already removed" } } }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["gone"]); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["gone"]); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); - }); - test("accepts a terminal status and converges after a stale list", async () => { - const result = await runSweep({ - lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], - deletes: { stale: { status: 202 } }, + test("accepts a terminal status and converges after a stale list", async () => { + const result = await runSweep({ + lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], + deletes: { stale: { status: 202 } }, + }); + expect(result.exitCode).toBe(0); }); - expect(result.exitCode).toBe(0); - }); - test("retries a transient read failure but rejects malformed evidence", async () => { - const transient = await runSweep({ - lists: [503, [active("retry")], []], - deletes: { retry: { status: 202 } }, - }); - expect(transient.exitCode).toBe(0); + test( + "retries a transient read failure but rejects malformed evidence", + async () => { + const transient = await runSweep({ + lists: [503, [active("retry")], []], + deletes: { retry: { status: 202 } }, + }); + expect(transient.exitCode).toBe(0); - const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); - expect(malformed.exitCode).not.toBe(0); - expect(malformed.deletes).toEqual([]); - }); + const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.deletes).toEqual([]); + }, + sweepBudget(2), + ); - test("fails terminal listing errors without retrying or deleting", async () => { - const forbidden = await runSweep({ lists: [403, []], deletes: {} }); - expect(forbidden.exitCode).not.toBe(0); - expect(forbidden.deletes).toEqual([]); + test( + "fails terminal listing errors without retrying or deleting", + async () => { + const forbidden = await runSweep({ lists: [403, []], deletes: {} }); + expect(forbidden.exitCode).not.toBe(0); + expect(forbidden.deletes).toEqual([]); - const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); - expect(unavailable.exitCode).not.toBe(0); - expect(unavailable.deletes).toEqual([]); + const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); + expect(unavailable.exitCode).not.toBe(0); + expect(unavailable.deletes).toEqual([]); - const malformedReconciliation = await runSweep({ - lists: [[active("bad-evidence")], { projects: [] }], - deletes: { "bad-evidence": { status: 403 } }, - }); - expect(malformedReconciliation.exitCode).not.toBe(0); - }); + const malformedReconciliation = await runSweep({ + lists: [[active("bad-evidence")], { projects: [] }], + deletes: { "bad-evidence": { status: 403 } }, + }); + expect(malformedReconciliation.exitCode).not.toBe(0); + }, + sweepBudget(3), + ); - test("attempts every owned project and fails if one remains active", async () => { - const result = await runSweep({ - lists: [ - [active("stuck"), active("other"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], - [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], - ], - deletes: { stuck: { status: 403 }, other: { status: 204 } }, + test("attempts every owned project and fails if one remains active", async () => { + const result = await runSweep({ + lists: [ + [ + active("stuck"), + active("other"), + { ref: "foreign", name: "unrelated", status: "ACTIVE" }, + ], + [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], + ], + deletes: { stuck: { status: 403 }, other: { status: 204 } }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.deletes).toEqual(["stuck", "other"]); }); - expect(result.exitCode).not.toBe(0); - expect(result.deletes).toEqual(["stuck", "other"]); - }); - test("retries a transient deletion after fresh evidence still shows the project", async () => { - const result = await runSweep({ - lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), - deletes: {}, - deleteStatuses: { flaky: [503, 204] }, + test("retries a transient deletion after fresh evidence still shows the project", async () => { + const result = await runSweep({ + lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), + deletes: {}, + deleteStatuses: { flaky: [503, 204] }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["flaky", "flaky"]); + expect(result.stderr).toContain( + "delete retry completed for project flaky (HTTP 204 request delete-flaky)", + ); }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["flaky", "flaky"]); - expect(result.stderr).toContain( - "delete retry completed for project flaky (HTTP 204 request delete-flaky)", - ); - }); -}); + }, +); diff --git a/apps/cli/scripts/upload-release-assets.integration.test.ts b/apps/cli/scripts/upload-release-assets.integration.test.ts index 1866370852..699cae4bf1 100644 --- a/apps/cli/scripts/upload-release-assets.integration.test.ts +++ b/apps/cli/scripts/upload-release-assets.integration.test.ts @@ -91,20 +91,22 @@ describe("upload-release-assets against a fake gh", () => { await uploadAssets("v1.0.0", [asset], io, { maxAttempts: 2, - timeoutMs: 500, + timeoutMs: 5_000, backoffMs: () => 0, }); - expect(Date.now() - startedAt).toBeLessThan(10_000); + const elapsed = Date.now() - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000); + expect(elapsed).toBeLessThan(25_000); expect(await calls()).toEqual([ `release upload v1.0.0 ${asset.path} --clobber`, `release upload v1.0.0 ${asset.path} --clobber`, ]); expect(logs).toEqual([ - `Upload of ${asset.name} failed on attempt 1 (timed out after 0.5s); retrying in 0s.`, + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, `Uploaded ${asset.name} (attempt 2).`, ]); - }, 20_000); + }, 40_000); test("kills an upload that ignores SIGTERM once the grace period passes", async () => { const { directory, env, calls } = await fakeGhOnPath(); @@ -114,18 +116,18 @@ describe("upload-release-assets against a fake gh", () => { await uploadAssets("v1.0.0", [asset], io, { maxAttempts: 2, - timeoutMs: 500, + timeoutMs: 5_000, backoffMs: () => 0, }); const elapsed = Date.now() - startedAt; - expect(elapsed).toBeGreaterThanOrEqual(500 + KILL_GRACE_MS); - expect(elapsed).toBeLessThan(20_000); + expect(elapsed).toBeGreaterThanOrEqual(5_000 + KILL_GRACE_MS); + expect(elapsed).toBeLessThan(25_000); expect(await calls()).toHaveLength(2); expect(logs[0]).toBe( - `Upload of ${asset.name} failed on attempt 1 (timed out after 0.5s); retrying in 0s.`, + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, ); - }, 30_000); + }, 40_000); test("surfaces gh's stderr for a failed attempt and retries it", async () => { const { directory, env, calls } = await fakeGhOnPath(); diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts index bb03844234..006cbf4ae6 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { resolvePinnedImage } from "./pinned-image.ts"; const currentTag = (alias: string) => dockerfileServiceImageRaw(alias).split(":")[1] ?? ""; @@ -24,10 +27,10 @@ describe("resolvePinnedImage", () => { }); it("resolves slim images when the flag is on and the pin is current", () => { - expect(resolvePinnedImage("gotrue", "auth", {}, true)).toBe(toSlimImage("gotrue", currentAuth)); - expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag }, true)).toBe( - toSlimImage("gotrue", currentAuth), - ); + const pinned = expectedPinnedImage("gotrue", currentAuth); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + expect(resolvePinnedImage("gotrue", "auth", {}, true)).toBe(pinned); + expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag }, true)).toBe(pinned); }); it("keeps a historical pin on docker.io", () => { @@ -43,21 +46,18 @@ describe("resolvePinnedImage", () => { }); it("normalizes a current pooler pin onto the slim tag scheme", () => { + const pinned = expectedPinnedImage("supavisor", currentPooler); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); expect(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag }, true)).toBe( - toSlimImage("supavisor", currentPooler), + pinned, ); - expect( - resolvePinnedImage( - "supavisor", - "pooler", - { - pooler: currentPoolerTag.startsWith("v") - ? currentPoolerTag.slice(1) - : `v${currentPoolerTag}`, - }, - true, - ), - ).toBe(toSlimImage("supavisor", currentPooler)); + // The opposite `v`-prefix variant of the same pin still counts as current + // (`pinMatchesCurrentImage` normalizes both before comparing), so it resolves to the very same + // catalog-pinned slim image. + const altPoolerTag = currentPoolerTag.startsWith("v") + ? currentPoolerTag.slice(1) + : `v${currentPoolerTag}`; + expect(resolvePinnedImage("supavisor", "pooler", { pooler: altPoolerTag }, true)).toBe(pinned); }); it("keeps a historical postgres pin on docker.io", () => { @@ -68,7 +68,7 @@ describe("resolvePinnedImage", () => { "supabase/postgres:17.4.1.1", ); expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag }, true)).toBe( - toSlimImage("pg", currentPostgres), + expectedPinnedImage("pg", currentPostgres), ); }); }); diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index cee9e8b932..a154f4aab2 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -74,7 +74,7 @@ export const resolveDbImage = Effect.fnUntraced(function* ( return { image, configImage: image }; } const slim = yield* slimImagesEnabled; - const currentRaw = postgresImageForDbMajorVersion(majorVersion, slim) ?? pgImageRaw(); + const currentRaw = postgresImageForDbMajorVersion(majorVersion) ?? pgImageRaw(); let appliedPin: string | undefined; if (majorVersion > 14) { const versionPath = path.join(workdir, "supabase", ".temp", "postgres-version"); diff --git a/apps/cli/src/command-internal/db-image.unit.test.ts b/apps/cli/src/command-internal/db-image.unit.test.ts index 6819ae401b..d66aabd1b2 100644 --- a/apps/cli/src/command-internal/db-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-image.unit.test.ts @@ -10,17 +10,17 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { - POSTGRES_FALLBACK_IMAGE_PG14, - POSTGRES_FALLBACK_IMAGE_PG15, - POSTGRES_FALLBACK_IMAGE_PG15_SLIM, -} from "../shared/services/services.shared.ts"; -import { imageTag, toSlimImage } from "../shared/services/slim-images.ts"; +import { imageTag } from "../shared/services/slim-images.ts"; +import { expectedPinnedImage, GHCR_SLIM_IMAGE_PATTERN } from "../../tests/helpers/slim-images.ts"; import { resolveDbImage } from "./db-image.ts"; const currentPostgres = dockerfileServiceImageRaw("pg"); const currentPostgresTag = imageTag(currentPostgres) ?? ""; -const pg15SlimTag = imageTag(POSTGRES_FALLBACK_IMAGE_PG15_SLIM) ?? ""; +// PG13/15 and PG14 now come from the Dockerfile's generated `pg15`/hand-pinned `pg14` stages +// (the single version table), not hardcoded fallback constants. +const pg15Image = dockerfileServiceImageRaw("pg15"); +const pg15Tag = imageTag(pg15Image) ?? ""; +const pg14Image = dockerfileServiceImageRaw("pg14"); const withTemp = () => mkdtempSync(join(tmpdir(), "db-image-")); @@ -49,16 +49,16 @@ describe("resolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { expect(yield* resolve(dir, 13)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG15, - configImage: POSTGRES_FALLBACK_IMAGE_PG15, + image: pg15Image, + configImage: pg15Image, }); expect(yield* resolve(dir, 14)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG14, - configImage: POSTGRES_FALLBACK_IMAGE_PG14, + image: pg14Image, + configImage: pg14Image, }); expect(yield* resolve(dir, 15)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG15, - configImage: POSTGRES_FALLBACK_IMAGE_PG15, + image: pg15Image, + configImage: pg15Image, }); expect(yield* resolve(dir, 17)).toEqual({ image: dockerfileServiceImage("pg", false), @@ -93,8 +93,8 @@ describe("resolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { expect(yield* resolve(dir, 14, "16.0.0.1")).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG14, - configImage: POSTGRES_FALLBACK_IMAGE_PG14, + image: pg14Image, + configImage: pg14Image, }); rmSync(dir, { recursive: true, force: true }); }); @@ -106,24 +106,26 @@ describe("resolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { expect(yield* resolve(dir, 14)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG14, - configImage: POSTGRES_FALLBACK_IMAGE_PG14, + image: pg14Image, + configImage: pg14Image, }); rmSync(dir, { recursive: true, force: true }); }); }); - it.effect("rewrites the current PG15 fallback to the slim registry", () => { + it.effect("rewrites the current PG15 default tag to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = withTemp(); + const pinned = expectedPinnedImage("pg", pg15Image); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return Effect.gen(function* () { expect(yield* resolve(dir, 15)).toEqual({ - image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), - configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + image: pinned, + configImage: pg15Image, }); expect(yield* resolve(dir, 13)).toEqual({ - image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), - configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + image: pinned, + configImage: pg15Image, }); rmSync(dir, { recursive: true, force: true }); }); @@ -142,14 +144,16 @@ describe("resolveDbImage", () => { }); }); - it.effect("rewrites a current PG15 pin to the slim registry", () => { + it.effect("rewrites a current PG15 pin to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = withTemp(); - writePin(dir, pg15SlimTag); + writePin(dir, pg15Tag); + const pinned = expectedPinnedImage("pg", pg15Image); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return Effect.gen(function* () { expect(yield* resolve(dir, 15)).toEqual({ - image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), - configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + image: pinned, + configImage: pg15Image, }); rmSync(dir, { recursive: true, force: true }); }); @@ -168,13 +172,15 @@ describe("resolveDbImage", () => { }); }); - it.effect("rewrites the current Dockerfile pin to the slim registry", () => { + it.effect("rewrites the current Dockerfile pin to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = withTemp(); writePin(dir, currentPostgresTag); + const pinned = expectedPinnedImage("pg", currentPostgres); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return Effect.gen(function* () { expect(yield* resolve(dir, 17)).toEqual({ - image: toSlimImage("pg", currentPostgres), + image: pinned, configImage: currentPostgres, }); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts index e791bb63e7..dcb6df4838 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts @@ -10,7 +10,8 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { slimImagesEnabled, toSlimImage } from "../shared/services/slim-images.ts"; +import { expectedPinnedImage, GHCR_SLIM_IMAGE_PATTERN } from "../../tests/helpers/slim-images.ts"; +import { slimImagesEnabled } from "../shared/services/slim-images.ts"; import { resolveEdgeRuntimeImage } from "./edge-runtime-image.ts"; const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); @@ -82,7 +83,7 @@ describe("resolveEdgeRuntimeImage", () => { ); }); - it.effect("rewrites the current Dockerfile pin onto the slim base", () => { + it.effect("rewrites the current Dockerfile pin to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const dir = mkdtempSync(join(tmpdir(), "edge-img-")); mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); @@ -90,10 +91,12 @@ describe("resolveEdgeRuntimeImage", () => { join(dir, "supabase", ".temp", "edge-runtime-version"), `${currentEdgeRuntimeTag}\n`, ); + const pinned = expectedPinnedImage("edgeruntime", currentEdgeRuntime); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return resolve(dir, 2).pipe( Effect.tap((image) => Effect.sync(() => { - expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); + expect(image).toBe(pinned); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index 8fe9849ca3..ca7dae1a4b 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -457,59 +457,62 @@ describe("stack shadow databases", () => { 120_000, ); - it.live("prints the cleanup commands when a shadow's destroy skips its engine", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-shadow-skipped-" }); - const output = mockOutput(); - const cleanupCommand = "docker rm --force $(docker ps --all --quiet)"; - // A real registration whose database creation fails fast and whose destroy reports skipped cleanup. - const api = Layer.effect( - StackApi, - Effect.gen(function* () { - const real = yield* StackApi; - return StackApi.of({ - ...real, - create: (options) => - real.create(options).pipe( - Effect.map((stack) => ({ - ...stack, - services: { - ...stack.services, - create: () => - Effect.fail(new StackError({ operation: "create", message: "injected" })), - }, - destroy: Effect.succeed({ - runtimeCleanup: "skipped", - engine: "docker", - cleanupCommands: [cleanupCommand], - } as const), - })), - ), - }); - }), - ).pipe(Layer.provide(stackApiLayer), Layer.provide(BunServices.layer)); + it.live( + "prints the cleanup commands when a shadow's destroy skips its engine", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-shadow-skipped-" }); + const output = mockOutput(); + const cleanupCommand = "docker rm --force $(docker ps --all --quiet)"; + // A real registration whose database creation fails fast and whose destroy reports skipped cleanup. + const api = Layer.effect( + StackApi, + Effect.gen(function* () { + const real = yield* StackApi; + return StackApi.of({ + ...real, + create: (options) => + real.create(options).pipe( + Effect.map((stack) => ({ + ...stack, + services: { + ...stack.services, + create: () => + Effect.fail(new StackError({ operation: "create", message: "injected" })), + }, + destroy: Effect.succeed({ + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [cleanupCommand], + } as const), + })), + ), + }); + }), + ).pipe(Layer.provide(stackApiLayer), Layer.provide(BunServices.layer)); - yield* stackWithShadowDatabase(input(fs, path, root), () => Effect.void, { - runtime: "native", - }).pipe( - Effect.provide( - Layer.mergeAll( - api, - stackCatalogSetupLayer, - dbConnectionLayer, - runtimeInfoLayer, - mockCommandSettings({ workdir: root, supabaseHome: root }), - output.layer, + yield* stackWithShadowDatabase(input(fs, path, root), () => Effect.void, { + runtime: "native", + }).pipe( + Effect.provide( + Layer.mergeAll( + api, + stackCatalogSetupLayer, + dbConnectionLayer, + runtimeInfoLayer, + mockCommandSettings({ workdir: root, supabaseHome: root }), + output.layer, + ), ), - ), - Effect.flip, - ); + Effect.flip, + ); - expect(output.stderrText).toMatch( - /Warning: Docker was unavailable, so Docker resources for shadow stack [0-9a-f]{64} were not removed\. Once it is running, remove them with:\n {2}docker rm --force \$\(docker ps --all --quiet\)\n/u, - ); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + expect(output.stderrText).toMatch( + /Warning: Docker was unavailable, so Docker resources for shadow stack [0-9a-f]{64} were not removed\. Once it is running, remove them with:\n {2}docker rm --force \$\(docker ps --all --quiet\)\n/u, + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + 120_000, ); }); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index 7d2a9ab96e..3cdd90f8a7 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -33,6 +33,48 @@ import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { declarativeSeamLayer } from "./pgdelta.seam.layer.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; +// This fixture catalog's pin must be keyed to the Dockerfile's own `pg` tag, or `toSlimImage` +// would find no match and fall back to the upstream (non-slim) image regardless of +// `SUPABASE_USE_SLIM_IMAGES` — masking the family-mismatch check below. `vi.hoisted` runs before +// every top-level `import` (including this file's own), so `dockerfileServiceImageRaw` isn't +// bound yet when this runs; `require` (CJS, unaffected by that ESM hoisting order) reads the +// Dockerfile directly instead, keeping this correct across every future Dockerfile bump. +const pgTag = vi.hoisted(() => { + const fs: typeof import("node:fs") = require("node:fs"); + const url: typeof import("node:url") = require("node:url"); + const dockerfilePath = url.fileURLToPath( + new URL("../../../shared/services/Dockerfile", import.meta.url), + ); + const match = /^FROM\s+supabase\/postgres:(\S+)\s+AS\s+pg$/m.exec( + fs.readFileSync(dockerfilePath, "utf8"), + ); + if (match?.[1] === undefined) throw new Error("pg tag not found in the Dockerfile"); + return match[1]; +}); + +vi.mock("@supabase/stack/internal/artifacts", () => { + const digest = "d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c"; + const nativePin = { archive: digest, manifest: digest }; + return { + catalogPins: () => [ + { + service: "database", + sourceService: "postgres", + pin: { + upstreamVersion: pgTag, + revision: 0, + image: `ghcr.io/supabase/cli/postgres:${pgTag}-r0@sha256:${digest}`, + natives: { + "darwin-arm64": nativePin, + "linux-amd64": nativePin, + "linux-arm64": nativePin, + }, + }, + }, + ], + }; +}); + /** * Integration coverage for the fully-native `declarativeSeamLayer`: `generate`/`sync`'s own * tests stub `DeclarativeSeam` entirely, so this file is the only place the real local-database @@ -207,6 +249,46 @@ describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { }, ); + it.effect("flags a stale slim container when only its revision has drifted from a hotfix", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = tmp.current; + // Same upstream version and family as the fixture catalog's pin, but at a different + // revision (r1, a different digest) — the hotfix-drift case this model exists to catch, + // and the one a bare upstream-version comparison would mask. + const { layer } = setup(dir, { + dbInspectImage: `ghcr.io/supabase/cli/postgres:${pgTag}-r1@sha256:${"a".repeat(64)}`, + }); + return Effect.gen(function* () { + const seam = yield* DeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = failError(exit); + expect(error).toBeInstanceOf(DeclarativeShadowDbError); + expect((error as DeclarativeShadowDbError).message).toContain( + "local Postgres container image is stale", + ); + // Same family (slim vs slim): the generic remediation, not the family-mismatch wording. + expect((error as DeclarativeShadowDbError).message).not.toContain( + "same SUPABASE_USE_SLIM_IMAGES setting", + ); + expect((error as DeclarativeShadowDbError).message).toContain("--no-backup"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("passes when a slim container matches the expected image's release and digest", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = tmp.current; + // The exact image (release version and digest) the fixture catalog pins at r0. + const { layer } = setup(dir, { + dbInspectImage: `ghcr.io/supabase/cli/postgres:${pgTag}-r0@sha256:d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c`, + }); + return Effect.gen(function* () { + const seam = yield* DeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.effect("bails out when inspect succeeds but the image name is unparseable", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = tmp.current; diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 514fba89f0..1223142205 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -9,7 +9,8 @@ import { resolveDbImage } from "../../../command-internal/db-image.ts"; import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { isDockerDaemonUnreachable } from "../../../command-internal/docker-suggest.ts"; -import { isSlimImageRef } from "../../../shared/services/slim-images.ts"; +import { imageDigest, imageTag, isSlimImageRef } from "../../../shared/services/slim-images.ts"; +import { upstreamVersionFromTag } from "../../../shared/services/services.shared.ts"; import { isLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; import { resolveLocalProjectId, localDbContainerId } from "../../../command-internal/docker-ids.ts"; @@ -250,20 +251,34 @@ export const declarativeSeamLayer = Layer.effect( ), Effect.map((value) => value.trim()), ); - const actualTag = dockerImageTag(actual); - const expectedTag = dockerImageTag(expected); - if (actual.length === 0 || actualTag.length === 0 || expectedTag.length === 0) { + const actualTag = imageTag(actual); + const expectedTag = imageTag(expected); + if (actual.length === 0 || actualTag === undefined || expectedTag === undefined) { return; } // Slim refs never go through a registry mirror, so a family mismatch // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli - // expectation) is stale even when the tags happen to match. + // expectation) is stale even when the upstream versions happen to match. const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); - if (!familyMismatch && actualTag === expectedTag) { - return; + if (!familyMismatch) { + // Same family: within slim, a `-r<N>` hotfix bump must still be caught, so compare + // the digest when both refs carry one (most precise), or the full tag otherwise — + // never the bare upstream version, which would mask a same-upstream revision drift. + const actualDigest = imageDigest(actual); + const expectedDigest = imageDigest(expected); + const current = + actualDigest !== undefined && expectedDigest !== undefined + ? actualDigest === expectedDigest + : actualTag === expectedTag; + if (current) return; } + // Across families, only the upstream version is comparable (a slim tag's `-r<N>` + // has no docker.io equivalent) — used to pick the remediation wording, not staleness: + // a family mismatch is always stale. + const upstreamMatches = + upstreamVersionFromTag(actualTag) === upstreamVersionFromTag(expectedTag); const remediation = - familyMismatch && actualTag === expectedTag + familyMismatch && upstreamMatches ? "The tags match but the image family does not (slim vs docker.io). Run supabase stop, then supabase start with the same SUPABASE_USE_SLIM_IMAGES setting before syncing declarative schemas." : "Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas."; return yield* new DeclarativeShadowDbError({ @@ -281,13 +296,6 @@ type StartLocalDatabaseDeps = ? R : never; -function dockerImageTag(image: string): string { - const trimmed = image.trim(); - const index = trimmed.lastIndexOf(":"); - if (index < 0 || index === trimmed.length - 1) return ""; - return trimmed.slice(index + 1); -} - export function isMissingContainerInspectError(stderr: string): boolean { return stderr.toLowerCase().includes("no such container"); } diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 2ede82a5ec..debb4eae72 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1577,7 +1577,10 @@ describe("db start stack backend", () => { const databaseCreation: Extract<ServiceCreation, { service: "database" }> = { service: "database", config: { - version: "17.6.1.173", + // Derived from the real catalog's major-17 pin, not hardcoded: `dbStart`'s stack-backend + // path resolves the desired version through the same catalog, so a bump would otherwise + // make the "resumes the existing database" test below see a spurious version mismatch. + version: postgresVersion("17"), databasePassword: Redacted.make("secret"), jwtSecret: Redacted.make("secret"), jwtExpiry: 3600, diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts index 2bfa8f6d78..7a37515ff9 100644 --- a/apps/cli/src/commands/services/services-local-stack.ts +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -6,6 +6,7 @@ import { import { Effect, Result } from "effect"; import { loadLocalProjectContext } from "../../command-internal/local-project-context.ts"; import { envOverrideMajorVersion } from "../../command-internal/local-config-values.ts"; +import { upstreamVersionFromTag } from "../../shared/services/services.shared.ts"; import type { ServiceVersionRow } from "../../shared/services/services.shared.ts"; import type { RemoteServiceName } from "../../shared/services/services.shared.ts"; @@ -53,7 +54,7 @@ export const stackServiceVersions = Effect.fn("services.stackServiceVersions")(f return { name, // A slim revision suffix (`-rN`) repackages the same upstream release. - local: artifact.version.replace(/-r\d+$/u, ""), + local: upstreamVersionFromTag(artifact.version), remote: remoteName === undefined ? "" : (remote[remoteName] ?? ""), } satisfies ServiceVersionRow; }), diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index b468afc5e6..839a972d36 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { catalogPins } from "@supabase/stack/internal/artifacts"; import { BunServices } from "@effect/platform-bun"; import { CliOutput, Command } from "effect/unstable/cli"; import { @@ -33,7 +34,6 @@ import { import { mockTelemetryStateTracked, useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../../shared/services/services.shared.ts"; -import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; @@ -43,6 +43,25 @@ import { services } from "./services.handler.ts"; const LOCAL_POSTGRES_VERSION = dockerfileServiceImageRaw("pg").split(":")[1] ?? ""; +/** + * The real stack catalog's pinned upstream version for `sourceService` — the default pin, or + * (for postgres) the additional 15.x line when `additional` is set. Derived rather than + * hardcoded, so a catalog bump never makes these "stack backend" assertions go stale. + */ +function catalogUpstreamVersion( + sourceService: string, + options: { readonly additional?: boolean } = {}, +): string { + const wantDefault = !(options.additional ?? false); + const entry = catalogPins().find( + (candidate) => candidate.sourceService === sourceService && candidate.isDefault === wantDefault, + ); + if (entry === undefined) { + throw new Error(`No catalog pin for '${sourceService}' (default=${wantDefault}).`); + } + return entry.pin.upstreamVersion; +} + /** Shape of one row in the `--output json` services array. */ const ServiceRows = Schema.Array( Schema.Struct({ @@ -189,8 +208,12 @@ const writeTempFile = Effect.fnUntraced(function* (workdir: string, name: string yield* fs.writeFileString(path.join(tempDir, name), content); }); -function postgresVersionForDbMajorVersion(majorVersion: number, slim: boolean): string { - const image = postgresImageForDbMajorVersion(majorVersion, slim); +// `postgresImageForDbMajorVersion` always returns the raw docker.io reference (slim translation +// is a downstream concern); its tag is the Dockerfile-generated one, which always matches the +// catalog's pinned upstream version, so this is the same version the CLI reports whether or not +// the slim flag is on. +function postgresVersionForDbMajorVersion(majorVersion: number): string { + const image = postgresImageForDbMajorVersion(majorVersion); if (image === undefined) { throw new Error(`Missing Postgres image for db major ${majorVersion}.`); } @@ -317,7 +340,7 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), + local: postgresVersionForDbMajorVersion(15), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -334,13 +357,22 @@ describe("services", () => { expect(out.stderrText).toBe(""); expect(rows).toHaveLength(13); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "15.14.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres", { additional: true }), + }), ); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/mailpit", local: "v1.30.2" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/mailpit", + local: catalogUpstreamVersion("mailpit"), + }), ); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/vector", local: "0.53.0" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/vector", + local: catalogUpstreamVersion("vector"), + }), ); expect(rows).toContainEqual( expect.objectContaining({ name: "ghcr.io/supabase/cli/storage", local: "v1.79.28" }), @@ -363,7 +395,10 @@ describe("services", () => { const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "15.14.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres", { additional: true }), + }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -381,10 +416,16 @@ describe("services", () => { const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "17.6.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres"), + }), ); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/auth", local: "v2.196.0" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/auth", + local: catalogUpstreamVersion("auth"), + }), ); expect(out.stderrText).toContain("unsupported PostgreSQL major version: 16"); expect(out.stderrText).toContain("using default stack catalog versions"); @@ -400,7 +441,10 @@ describe("services", () => { const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "17.6.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres"), + }), ); expect(out.stderrText).toMatch(/^failed to read config:/); expect(out.stderrText).toContain("using default stack catalog versions"); @@ -421,7 +465,7 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), + local: postgresVersionForDbMajorVersion(15), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -448,7 +492,7 @@ major_version = 15 expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), + local: postgresVersionForDbMajorVersion(15), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -566,7 +610,7 @@ major_version = 15 expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), + local: postgresVersionForDbMajorVersion(15), remote: "17.6.1.200", }), ); @@ -584,7 +628,7 @@ major_version = 15 expect(stackRows).toContainEqual( expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", - local: "17.6.1.173", + local: catalogUpstreamVersion("postgres"), remote: "17.6.1.200", }), ); diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index fac0f51757..ec49dab78b 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -222,11 +222,14 @@ const VECTOR_HEALTHCHECK = { } as const; /** - * Writes the rendered `vector.yaml` via a `cat <<'EOF'` heredoc, waits on Logflare's `/health` - * (sinks would otherwise start too early), then `exec`s Vector so it stays PID 1. A TERM trap - * covers the wait so `docker stop` does not burn 10s if Logflare is still down; `-T 2` bounds each - * probe so a hung health endpoint can't defer the trap. Slim Vector ships BusyBox wget, so the - * wait uses `-q --spider` instead of GNU's `--no-verbose --tries`. + * Creates `/etc/vector` (absent from Vector 0.58's images, both slim and upstream), writes the + * rendered `vector.yaml` via a `cat <<'EOF'` heredoc, waits on Logflare's `/health` (sinks would + * otherwise start too early), then `exec`s Vector so it stays PID 1. A TERM trap covers the wait + * so `docker stop` does not burn 10s if Logflare is still down; `-T 2` bounds each probe so a hung + * health endpoint can't defer the trap. Slim Vector ships BusyBox wget, so the wait uses + * `-q --spider` instead of GNU's `--no-verbose --tries`. Both images run as root, so `mkdir -p` + * needs no separate ownership handling, and `/var/lib/vector` (the `docker_logs` source's + * checkpoint `data_dir`) already exists in both. */ export function buildVectorEntrypointScript( vectorYaml: string, @@ -237,7 +240,7 @@ export function buildVectorEntrypointScript( ? slimWgetWaitCommand(`http://${logflareId}:4000/health`) : `wget --no-verbose --tries=1 -T 2 --spider http://${logflareId}:4000/health`; return ( - "cat <<'EOF' > /etc/vector/vector.yaml\n" + + "mkdir -p /etc/vector\ncat <<'EOF' > /etc/vector/vector.yaml\n" + vectorYaml + "\nEOF\ntrap 'exit 143' TERM\nuntil " + wget + diff --git a/apps/cli/src/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/commands/start/services/vector.service.unit.test.ts index c82f2bae8b..10c149fc10 100644 --- a/apps/cli/src/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/vector.service.unit.test.ts @@ -208,7 +208,7 @@ describe("resolveVectorDockerSocketPlan", () => { describe("buildVectorEntrypointScript", () => { test("writes vector.yaml then waits on Logflare's health endpoint before exec'ing vector (start.go:449-454)", () => { expect(buildVectorEntrypointScript("VECTOR_YAML", "supabase_analytics_proj")).toBe( - "cat <<'EOF' > /etc/vector/vector.yaml\n" + + "mkdir -p /etc/vector\ncat <<'EOF' > /etc/vector/vector.yaml\n" + "VECTOR_YAML" + "\nEOF\ntrap 'exit 143' TERM\nuntil wget --no-verbose --tries=1 -T 2 --spider http://" + "supabase_analytics_proj" + diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index c7429d744b..59b3468509 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -338,4 +338,55 @@ describe("supabase start (e2e)", () => { }).pipe(Effect.provide(BunServices.layer)), START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, ); + + // Only Postgres, Logflare, and Vector run. Every other scenario here excludes Logflare and Vector, + // so this is the one that exercises the Vector image's entrypoint end to end. + it.live( + "starts Vector against a real Logflare and reaches healthy", + () => + Effect.gen(function* () { + const projectDir = yield* makeProject("sb-start-e2e-vector-"); + const path = yield* Path.Path; + const projectId = sanitizeProjectId(path.basename(projectDir)); + const vectorContainer = serviceContainerName("vector", projectId); + + const init = yield* runSupabaseEffect(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + yield* overridePorts(projectDir); + + const excludeArgs = SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || + entry.excludeKey === "logflare" || + entry.excludeKey === "vector" + ? [] + : ["--exclude", entry.excludeKey], + ); + const start = yield* runSupabaseEffect(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + + if (start.exitCode !== 0) { + const logs = yield* runDockerEffect(["logs", vectorContainer]).pipe( + Effect.map(({ stdout, stderr }) => `${stdout}${stderr}`.trim() || "<empty>"), + Effect.catch((error) => Effect.succeed(`<unavailable: ${error.message}>`)), + ); + throw new Error( + `start failed (exit ${start.exitCode})\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}\n${vectorContainer} logs:\n${logs}`, + ); + } + + const health = yield* runDockerEffect([ + "inspect", + vectorContainer, + "--format", + "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", + ]); + expect(health.stdout.trim()).toBe("healthy"); + }).pipe(Effect.provide(BunServices.layer)), + START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index 8443a5eecd..b34feac5c3 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; -import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { serviceContainerIds, localDbContainerId } from "../../command-internal/docker-ids.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; import { resolveStartGates, resolveStartImagePlan, type StartGates } from "./start.gates.ts"; @@ -248,13 +251,20 @@ describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { }); it("plans slim images when the flag is on, keeping unmapped services on docker.io", () => { - expect(imageFor("gotrue", true)).toBe(toSlimImage("gotrue", currentGotrue)); - expect(imageFor("logflare", true)).toBe(toSlimImage("logflare", currentLogflare)); - expect(imageFor("vector", true)).toBe(toSlimImage("vector", currentVector)); - expect(imageFor("supavisor", true, { pooler: currentPoolerTag })).toBe( - toSlimImage("supavisor", currentPooler), - ); + const gotruePinned = expectedPinnedImage("gotrue", currentGotrue); + const logflarePinned = expectedPinnedImage("logflare", currentLogflare); + const vectorPinned = expectedPinnedImage("vector", currentVector); + const poolerPinned = expectedPinnedImage("supavisor", currentPooler); + for (const pinned of [gotruePinned, logflarePinned, vectorPinned, poolerPinned]) { + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + } + expect(imageFor("gotrue", true)).toBe(gotruePinned); + expect(imageFor("logflare", true)).toBe(logflarePinned); + expect(imageFor("vector", true)).toBe(vectorPinned); + expect(imageFor("supavisor", true, { pooler: currentPoolerTag })).toBe(poolerPinned); + // Deliberate fallback: a historical pin the catalog doesn't carry stays on docker.io. expect(imageFor("supavisor", true, { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + // Deliberate fallback: kong has no slim build at all. expect(imageFor("kong", true)).toBe("library/kong:2.8.1"); }); }); diff --git a/apps/cli/src/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/commands/start/start.slim-images.e2e.test.ts index 65dae6a808..2a44aacfb3 100644 --- a/apps/cli/src/commands/start/start.slim-images.e2e.test.ts +++ b/apps/cli/src/commands/start/start.slim-images.e2e.test.ts @@ -2,9 +2,14 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { BunServices } from "@effect/platform-bun"; import { Clock, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { beforeAll, describe, expect, it } from "@effect/vitest"; +import { catalogPins, type ServiceKind } from "@supabase/stack/internal/artifacts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { isSlimImageRef, toSlimImage } from "../../shared/services/slim-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { buildHealthCmdArg } from "../../command-internal/db-bootstrap/docker-create-args.ts"; import { slimWgetHealthcheck, @@ -63,20 +68,29 @@ const PULL_ALIASES = [ "mailpit", "kong", ] as const; -/** Slim images whose in-container probe is BusyBox wget. */ -const WGET_PROBE_ALIASES = [ - "gotrue", +/** + * Catalog services whose slim image ships BusyBox wget as its in-container probe (not the + * Dockerfile-derived image: whether the Dockerfile's tag currently matches that catalog pin is + * unrelated to whether the pinned slim image itself accepts the BusyBox argv). + */ +const WGET_PROBE_SERVICES: ReadonlyArray<ServiceKind> = [ + "auth", "realtime", "storage", - "logflare", - "supavisor", + "analytics", "vector", -] as const; + "pooler", +]; + +function wgetProbeCatalogImages(): ReadonlyArray<string> { + return catalogPins() + .filter((entry) => WGET_PROBE_SERVICES.includes(entry.service)) + .map((entry) => entry.pin.image); +} function latestImagesToPull(): ReadonlyArray<string> { - return [...new Set([...PULL_ALIASES, ...WGET_PROBE_ALIASES])].map((alias) => - toSlimImage(alias, dockerfileServiceImageRaw(alias)), - ); + const dockerfileImages = PULL_ALIASES.map((alias) => expectedSlimImage(alias)); + return [...new Set([...dockerfileImages, ...wgetProbeCatalogImages()])]; } function readSectionPort(config: string, section: string): number { @@ -92,8 +106,73 @@ const containerImage = Effect.fnUntraced(function* (name: string) { return stdout.trim(); }); +/** `kong` has no slim build at all, so this stays fallback-tolerant for the pull-ahead list. */ function expectedSlimImage(alias: string): string { - return toSlimImage(alias, dockerfileServiceImageRaw(alias)); + const raw = dockerfileServiceImageRaw(alias); + return toSlimImage(alias, raw) ?? raw; +} + +/** + * The Dockerfile aliases whose spec builder switches its healthcheck on `usesSlimImageRuntime` + * (`*.service.ts`). Every one of them is slim today (`expectedPinnedImage`), but the assertions + * below still derive the expected healthcheck from the image actually resolved, not from that + * assumption directly. + */ +type HealthcheckedAlias = "gotrue" | "storage" | "realtime"; + +/** Mirrors each service's own upstream (non-slim) `healthcheck.test`, `CMD` prefix included. */ +function upstreamHealthcheckTest(alias: HealthcheckedAlias): ReadonlyArray<string> { + switch (alias) { + case "gotrue": + return [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:9999/health", + ]; + case "storage": + return [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ]; + case "realtime": + return [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + `Host:${REALTIME_TENANT_ID}`, + "http://127.0.0.1:4000/api/ping", + ]; + } +} + +/** Mirrors each service's own slim (BusyBox wget) `healthcheck.test`, `CMD` prefix included. */ +function slimHealthcheckTest(alias: HealthcheckedAlias): ReadonlyArray<string> { + switch (alias) { + case "gotrue": + return slimWgetHealthcheck("http://127.0.0.1:9999/health").test; + case "storage": + return slimWgetHealthcheck("http://127.0.0.1:5000/status").test; + case "realtime": + return slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { + header: `Host:${REALTIME_TENANT_ID}`, + }).test; + } +} + +/** The `healthcheck.test` a container running `image` must have — matched to its family. */ +function expectedHealthcheckTest(alias: HealthcheckedAlias, image: string): ReadonlyArray<string> { + return isSlimImageRef(image) ? slimHealthcheckTest(alias) : upstreamHealthcheckTest(alias); } const containerHealthcheckTest = Effect.fnUntraced(function* (name: string) { @@ -185,19 +264,20 @@ describe("supabase start slim images (e2e)", () => { ); it.live( - "every slim wget image accepts the BusyBox healthcheck argv", + "every pinned slim wget image accepts the BusyBox healthcheck argv", () => Effect.gen(function* () { - for (const alias of WGET_PROBE_ALIASES) { - const image = expectedSlimImage(alias); + for (const entry of catalogPins()) { + if (!WGET_PROBE_SERVICES.includes(entry.service)) continue; + const image = entry.pin.image; const probe = - alias === "realtime" + entry.service === "realtime" ? slimWgetHealthcheck("http://127.0.0.1:9/", { header: `Host:${REALTIME_TENANT_ID}`, }) : slimWgetHealthcheck("http://127.0.0.1:9/"); expectBusyBoxAccepted(yield* runWgetInImage(image, probe.test.slice(2)), image); - if (alias === "vector") { + if (entry.service === "vector") { const waitArgs = slimWgetWaitCommand("http://127.0.0.1:9/").split(" ").slice(1); expectBusyBoxAccepted(yield* runWgetInImage(image, waitArgs), `${image} wait`); } @@ -207,7 +287,7 @@ describe("supabase start slim images (e2e)", () => { ); it.live( - "starts the latest slim images, serves a function without a version pin, and keeps the Dockerfile tag", + "starts each service on its resolved image (slim or upstream), matching healthchecks to family, and serves a function without a version pin", () => Effect.gen(function* () { const projectDir = yield* makeProject("sb-slim-start-e2e-"); @@ -244,25 +324,40 @@ describe("supabase start slim images (e2e)", () => { }); expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - expect(yield* containerImage(dbContainer)).toBe(expectedSlimImage("pg")); - expect(yield* containerImage(storageContainer)).toBe(expectedSlimImage("storage")); - expect(yield* containerImage(edgeRuntimeContainer)).toBe(expectedSlimImage("edgeruntime")); + // Every alias here is slim-capable, and its default Dockerfile tag is generated from the + // catalog, so it matches a catalog pin — so each expected image is read straight from the catalog + // (`expectedPinnedImage`), independent of `toSlimImage`. + const authImage = expectedPinnedImage("gotrue", dockerfileServiceImageRaw("gotrue")); + const realtimeImage = expectedPinnedImage( + "realtime", + dockerfileServiceImageRaw("realtime"), + ); + const storageImage = expectedPinnedImage("storage", dockerfileServiceImageRaw("storage")); + for (const image of [authImage, realtimeImage, storageImage]) { + expect(image).toMatch(GHCR_SLIM_IMAGE_PATTERN); + } + + expect(yield* containerImage(dbContainer)).toBe( + expectedPinnedImage("pg", dockerfileServiceImageRaw("pg")), + ); + expect(yield* containerImage(storageContainer)).toBe(storageImage); + expect(yield* containerImage(edgeRuntimeContainer)).toBe( + expectedPinnedImage("edgeruntime", dockerfileServiceImageRaw("edgeruntime")), + ); + expect(yield* containerImage(authContainer)).toBe(authImage); + expect(yield* containerImage(realtimeContainer)).toBe(realtimeImage); expect(yield* containerHealthcheckTest(authContainer)).toEqual([ "CMD-SHELL", - buildHealthCmdArg(slimWgetHealthcheck("http://127.0.0.1:9999/health").test), + buildHealthCmdArg(expectedHealthcheckTest("gotrue", authImage)), ]); expect(yield* containerHealthcheckTest(realtimeContainer)).toEqual([ "CMD-SHELL", - buildHealthCmdArg( - slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { - header: `Host:${REALTIME_TENANT_ID}`, - }).test, - ), + buildHealthCmdArg(expectedHealthcheckTest("realtime", realtimeImage)), ]); expect(yield* containerHealthcheckTest(storageContainer)).toEqual([ "CMD-SHELL", - buildHealthCmdArg(slimWgetHealthcheck("http://127.0.0.1:5000/status").test), + buildHealthCmdArg(expectedHealthcheckTest("storage", storageImage)), ]); expect(yield* containerHealthStatus(authContainer)).toBe("healthy"); expect(yield* containerHealthStatus(realtimeContainer)).toBe("healthy"); diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts index 64e5a3af49..57e310bf51 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Effect } from "effect"; import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { DENO1_EDGE_RUNTIME_VERSION, edgeRuntimeImage, @@ -22,10 +26,10 @@ describe("edgeRuntimeImage", () => { ); }); - it("rewrites the current Dockerfile tag onto the slim ghcr.io image when the flag is on", () => { - expect(edgeRuntimeImage(currentEdgeRuntimeTag, true)).toBe( - `ghcr.io/supabase/cli/edge-runtime:${currentEdgeRuntimeTag}`, - ); + it("rewrites the current Dockerfile tag onto the catalog-pinned slim ghcr.io image when the flag is on", () => { + const pinned = expectedPinnedImage("edgeruntime", rawEdgeRuntimeImage); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + expect(edgeRuntimeImage(currentEdgeRuntimeTag, true)).toBe(pinned); }); it("keeps a historical pin on docker.io when the flag is on", () => { diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index a9c85ee0ca..aee8c87f83 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -1,21 +1,29 @@ -# Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.171 AS pg +# The tag pinned below is generated from packages/stack/src/Artifacts.ts by +# apps/cli/scripts/render-service-dockerfile.ts, for these aliases only: pg, pg15, mailpit, +# postgrest, pgmeta, studio, imgproxy, edgeruntime, vector, supavisor, gotrue, realtime, storage, +# logflare. Do not hand-edit those tags; a CI drift check enforces this. Run the generator after +# a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job +# images) is hand- or Dependabot-managed. +FROM supabase/postgres:17.11.0.002 AS pg +FROM supabase/postgres:15.19.0.002 AS pg15 +# Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). +FROM supabase/postgres:14.1.0.89 AS pg14 # New always-on service alias: extend SERVICE_IMAGE_ALIASES in # shared/services/services.shared.ts and DOCKERFILE_ALIAS_BY_SERVICE in # commands/start/start.gates.ts. FROM library/kong:2.8.1 AS kong -FROM axllent/mailpit:v1.30.2 AS mailpit -FROM postgrest/postgrest:v16.3 AS postgrest +FROM axllent/mailpit:v1.31.3 AS mailpit +FROM postgrest/postgrest:v16.4 AS postgrest FROM supabase/postgres-meta:v0.99.0 AS pgmeta -FROM supabase/studio:2026.09.14-sha-4dd8a95 AS studio -FROM darthsim/imgproxy:v3.8.0 AS imgproxy +FROM supabase/studio:2026.09.28-sha-5e59b60 AS studio +FROM darthsim/imgproxy:v3.26.0 AS imgproxy FROM supabase/edge-runtime:v1.77.1 AS edgeruntime -FROM timberio/vector:0.53.0-alpine AS vector +FROM timberio/vector:0.58.0-alpine AS vector FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.135.3 AS realtime -FROM supabase/storage-api:v1.77.0 AS storage -FROM supabase/logflare:1.50.12 AS logflare +FROM supabase/realtime:v2.140.3 AS realtime +FROM supabase/storage-api:v1.79.28 AS storage +FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts b/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts deleted file mode 100644 index 5471886e14..0000000000 --- a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; -import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; - -// The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed -// yet; this keeps the two copies in sync until apps/cli-go is deleted, at which point -// this test should be deleted alongside it. -describe("Go Dockerfile sync guard", () => { - it.effect("keeps the Go tree's embedded Dockerfile byte-identical to the TS-owned copy", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const goDockerfile = yield* fs.readFileString( - path.resolve(import.meta.dirname, "../../../../cli-go/pkg/config/templates/Dockerfile"), - ); - expect(goDockerfile).toBe(serviceImagesDockerfile); - }).pipe(Effect.provide(BunServices.layer)), - ); -}); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index fb86318605..3f0f553586 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -10,11 +10,18 @@ import { ErrorActionabilityId, } from "../telemetry/error-actionability.ts"; import { + dockerfileServiceImageRaw, dockerfileServiceImages, parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; -import { slimImageForAlias, slimImageForCurrentPin } from "./slim-images.ts"; +import { + imageRepository, + imageTag, + replaceImageTag, + slimImageForAlias, + slimImageForCurrentPin, +} from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -116,35 +123,25 @@ export function localServiceImagesFromDockerfile( const LOCAL_SERVICE_IMAGES = localServiceImagesFromSpecs(dockerfileServiceImages); -export const POSTGRES_FALLBACK_IMAGE_PG14 = "supabase/postgres:14.1.0.89"; -/** Flag-off PG13/15 docker.io pin. */ -export const POSTGRES_FALLBACK_IMAGE_PG15 = "supabase/postgres:15.8.1.085"; -/** Published slim PG15 pin; flag-on majors 13/15 slim-translate this, not 15.8. */ -export const POSTGRES_FALLBACK_IMAGE_PG15_SLIM = "supabase/postgres:15.14.1.167"; - -export function postgresImageForDbMajorVersion( - majorVersion: number, - slim: boolean, -): string | undefined { +/** + * Resolves PG13/14/15 against the Dockerfile's `pg15`/`pg14` stages — the single version table, + * generated (`pg15`) or hand-pinned (`pg14`, no slim build) from the stack catalog. Always the + * raw docker.io reference; slim translation happens downstream via the same `toSlimImage("pg", + * …)` path every other slim-capable service uses, since a slim-capable service's Dockerfile tag + * always matches a catalog upstream version by construction. + */ +export function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { switch (majorVersion) { case 13: case 15: - return slim ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; + return dockerfileServiceImageRaw("pg15"); case 14: - return POSTGRES_FALLBACK_IMAGE_PG14; + return dockerfileServiceImageRaw("pg14"); default: return undefined; } } -function replaceImageTag(image: string, tag: string): string { - const index = image.lastIndexOf(":"); - if (index === -1) { - return image; - } - return `${image.slice(0, index + 1)}${tag.trim()}`; -} - /** Applies that service's image-tag prefix when the version does not already start with it. */ export function tagForServiceVersion(service: LocalServiceVersionName, version: string): string { const trimmed = version.trim(); @@ -183,9 +180,11 @@ function localServiceImagesForOptions( if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; } + // `slim-images.ts`'s `replaceImageTag` doesn't trim (its own callers already do), so this + // path — the only one that skips `tagForServiceVersion`'s trim — trims here. const pin = normalizeVersionTags ? tagForServiceVersion(service.localService, version) - : version; + : version.trim(); if (override === undefined && slim) { return { ...service, @@ -216,17 +215,26 @@ export interface ServiceVersionRow { readonly remote: string; } +/** A release tag's `-r<N>` suffix, matching the slim-services revision grammar. */ +const RELEASE_REVISION_SUFFIX = /^(?<upstream>.+)-r(?:0|[1-9][0-9]*)$/; + +/** Strips a slim release tag's `-r<N>` suffix, if any, back to its upstream version. */ +export function upstreamVersionFromTag(tag: string): string { + return RELEASE_REVISION_SUFFIX.exec(tag)?.groups?.upstream ?? tag; +} + function toServiceVersionRow( service: ServiceImageSpec, remote: Partial<Record<RemoteServiceName, string>> = {}, ): ServiceVersionRow { - const tagSeparator = service.image.lastIndexOf(":"); - if (tagSeparator === -1) { + // `@`-aware (via `imageTag`/`imageRepository`): a slim catalog pin's image carries a + // `@sha256:…` digest after the tag. + const name = imageRepository(service.image); + const tag = imageTag(service.image); + if (name === undefined || tag === undefined) { throw new Error(`Invalid service image entry: ${service.image}`); } - - const name = service.image.slice(0, tagSeparator); - const local = service.image.slice(tagSeparator + 1); + const local = upstreamVersionFromTag(tag); return { name, diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index 2471e2423c..ff9e180f5c 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,17 +1,51 @@ import { describe, expect, it, test } from "@effect/vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vitest"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; +import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { fetchLinkedServiceVersions, listLocalServiceVersions, localServiceImagesFromDockerfile, + mergeRemoteServiceVersions, parseDockerfileServiceImages, postgresImageForDbMajorVersion, renderServicesTable, renderServicesWarning, } from "./services.shared.ts"; +// Only `auth` is pinned in this fixture catalog, at the current Dockerfile-independent version +// `v2.197.0-r0`, with a realistic (non-placeholder) fixture digest built to the real +// `ArtifactPin`/`NativePin` shape from `@supabase/stack/internal/artifacts`. Every other service +// is deliberately absent, so `toSlimImage` falls through to the upstream image for them — the +// permanent state for a non-slim-capable alias (kong, `pg14`, the job images): the Dockerfile's +// slim-capable lines are generated from the catalog now, so they never disagree with it. +// `vi.mock` factories are hoisted above every other top-level statement, so the fixture is +// inlined rather than referencing an outer const. +vi.mock("@supabase/stack/internal/artifacts", () => { + const digest = "260e94edb8d402555791146fcf70b8e90efdc6a81877a04e5aa26f0f416a5dd7"; + const nativePin = { archive: digest, manifest: digest }; + return { + catalogPins: () => [ + { + service: "auth", + sourceService: "auth", + pin: { + upstreamVersion: "v2.197.0", + revision: 0, + image: `ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:${digest}`, + natives: { + "darwin-arm64": nativePin, + "linux-amd64": nativePin, + "linux-arm64": nativePin, + }, + }, + }, + ], + }; +}); + const ACCESS_TOKEN = Redacted.make(`sbp_${"a".repeat(40)}`); const PROJECT_REF = "abcdefghijklmnopqrst"; @@ -75,26 +109,39 @@ describe("services shared", () => { ]); }); - test("keeps the established PG13/15 fallback unless the slim flag is on", () => { - expect(postgresImageForDbMajorVersion(13, false)).toBe("supabase/postgres:15.8.1.085"); - expect(postgresImageForDbMajorVersion(15, false)).toBe("supabase/postgres:15.8.1.085"); - expect(postgresImageForDbMajorVersion(13, true)).toBe("supabase/postgres:15.14.1.167"); - expect(postgresImageForDbMajorVersion(15, true)).toBe("supabase/postgres:15.14.1.167"); + test("resolves PG13/14/15 from the Dockerfile's pg15/pg14 stages, regardless of the slim flag", () => { + const pg15 = dockerfileServiceImageRaw("pg15"); + const pg14 = dockerfileServiceImageRaw("pg14"); + expect(postgresImageForDbMajorVersion(13)).toBe(pg15); + expect(postgresImageForDbMajorVersion(15)).toBe(pg15); + expect(postgresImageForDbMajorVersion(14)).toBe(pg14); + // Always the raw docker.io reference; slim translation is a separate, downstream concern + // (`toSlimImage`/`slimImageForCurrentPin`), so this function itself doesn't read the flag. + expect(postgresImageForDbMajorVersion(13)).toBe(pg15); + expect(postgresImageForDbMajorVersion(15)).toBe(pg15); + expect(postgresImageForDbMajorVersion(14)).toBe(pg14); }); - test("lists slim images when SUPABASE_USE_SLIM_IMAGES is set", () => { - expect(listLocalServiceVersions({ slim: true }).map((row) => row.name)).toEqual([ - "ghcr.io/supabase/cli/postgres", - "ghcr.io/supabase/cli/auth", - "ghcr.io/supabase/cli/postgrest", - "ghcr.io/supabase/cli/realtime", - "ghcr.io/supabase/cli/storage", - "ghcr.io/supabase/cli/edge-runtime", - "ghcr.io/supabase/cli/studio", - "ghcr.io/supabase/cli/pgmeta", - "ghcr.io/supabase/cli/analytics", - "ghcr.io/supabase/cli/pooler", - ]); + test("slim-translates a version override that matches a catalog pin", () => { + expect( + listLocalServiceVersions({ slim: true, serviceVersions: { auth: "v2.197.0" } }), + ).toContainEqual({ + name: "ghcr.io/supabase/cli/auth", + // The catalog's release version (`v2.197.0-r0`) is a Dockerfile/manifest tag; the row shows + // the upstream version so a `supabase services` mismatch check compares upstream to upstream. + local: "v2.197.0", + remote: "", + }); + }); + + test("keeps a version override that isn't in the catalog on docker.io", () => { + expect( + listLocalServiceVersions({ slim: true, serviceVersions: { storage: "v1.70.3" } }), + ).toContainEqual({ + name: "supabase/storage-api", + local: "v1.70.3", + remote: "", + }); }); test("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { @@ -122,16 +169,6 @@ describe("services shared", () => { ).toContainEqual({ name: "supabase/gotrue", local: "v2.151.0", remote: "" }); }); - test("slim-translates catalog version overrides that are not the Dockerfile pin", () => { - expect( - listLocalServiceVersions({ slim: true, serviceVersions: { storage: "v1.70.3" } }), - ).toContainEqual({ - name: "ghcr.io/supabase/cli/storage", - local: "v1.70.3", - remote: "", - }); - }); - // Explicit overrides keep their registry; a serviceVersions pin still rewrites the tag. test("leaves explicit image overrides on docker.io when SUPABASE_USE_SLIM_IMAGES is set", () => { const rows = listLocalServiceVersions({ @@ -152,6 +189,27 @@ describe("services shared", () => { ); }); + // A digest-carrying override paired with a serviceVersions pin exercises the same + // `replaceImageTag` used for the plain-tag case above; it must drop the stale digest + // rather than splice the new tag into it (`…-r0@sha256:<pin>`). + test("rewrites the tag on a digest-carrying image override, dropping the stale digest", () => { + const rows = listLocalServiceVersions({ + slim: true, + imageOverrides: { + postgres: + "ghcr.io/supabase/cli/postgres:17.6.1.173-r0@sha256:24e96b8d5daf90f67a62b5593d3008446744007e0a57e302d269c02a4e459e8f", + }, + normalizeVersionTags: false, + serviceVersions: { postgres: "17.6.1.200" }, + }); + + expect(rows).toContainEqual({ + name: "ghcr.io/supabase/cli/postgres", + local: "17.6.1.200", + remote: "", + }); + }); + test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ @@ -501,4 +559,20 @@ describe("services shared", () => { ]), ).toContain("supabase/postgres:17.6.1.132 => 17.6.1.200"); }); + + test("compares upstream versions for a slim catalog pin, not the release tag", () => { + const rows = mergeRemoteServiceVersions( + { auth: "v2.197.0" }, + { slim: true, serviceVersions: { auth: "v2.197.0" } }, + ); + + expect(rows).toContainEqual({ + name: "ghcr.io/supabase/cli/auth", + local: "v2.197.0", + remote: "v2.197.0", + }); + // The pinned image's release tag (`v2.197.0-r0@sha256:…`) never surfaces as a mismatch + // against the upstream-only remote version. + expect(renderServicesWarning(rows)).toBeUndefined(); + }); }); diff --git a/apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts b/apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts new file mode 100644 index 0000000000..ea5fd0dae4 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; +import { slimCatalogPin, toSlimImage } from "./slim-images.ts"; + +/** + * Every slim-capable Dockerfile alias, against the real (unmocked) catalog. The Dockerfile is + * generated from that same catalog (`render-service-dockerfile.ts`), so each alias's raw tag is + * always one of the catalog's own upstream versions by construction — no fixture, no + * `vi.mock`, exercising the Dockerfile/catalog alignment end to end. + */ +const SLIM_CAPABLE_ALIASES = [ + "pg", + "pg15", + "gotrue", + "postgrest", + "realtime", + "storage", + "edgeruntime", + "studio", + "pgmeta", + "logflare", + "supavisor", + "vector", + "imgproxy", + "mailpit", +] as const; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("slim mode against the real catalog", () => { + it.each(SLIM_CAPABLE_ALIASES)("resolves the %s alias to its pinned catalog image", (alias) => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const raw = dockerfileServiceImageRaw(alias); + const pin = slimCatalogPin(alias, raw); + expect(pin).toBeDefined(); + const resolved = toSlimImage(alias, raw); + expect(resolved).toBeDefined(); + expect(resolved).toMatch(/^ghcr\.io\/supabase\/cli\//); + }); + + it("has no slim entry for aliases with no slim build", () => { + for (const alias of ["kong", "pg14", "differ", "migra", "pgprove"]) { + const raw = dockerfileServiceImageRaw(alias); + expect(toSlimImage(alias, raw)).toBeUndefined(); + } + }); +}); diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index e5fb6b0521..440e3a5284 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -1,3 +1,4 @@ +import { catalogPins } from "@supabase/stack/internal/artifacts"; import { Config, ConfigProvider, Effect, Option } from "effect"; const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; @@ -5,11 +6,13 @@ const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; /** * Maps embedded-Dockerfile aliases onto the slim service catalog. Aliases with - * no slim build (kong, the `differ`/`migra`/`pgprove` job images) are absent and - * keep their docker.io reference. OrioleDB tags are excluded in `slimCatalogPin`. + * no slim build (kong, `pg14`, the `differ`/`migra`/`pgprove` job images) are + * absent and keep their docker.io reference. OrioleDB tags are excluded in + * `slimCatalogPin`. */ const SLIM_SERVICE_BY_ALIAS = { pg: "postgres", + pg15: "postgres", gotrue: "auth", postgrest: "postgrest", realtime: "realtime", @@ -70,7 +73,7 @@ export interface SlimCatalogPin { } /** OrioleDB tags are docker.io-only; slim-services does not publish them. */ -export function isOrioleImage(image: string): boolean { +function isOrioleImage(image: string): boolean { const tag = imageTag(image); return tag !== undefined && tag.toLowerCase().includes("orioledb"); } @@ -98,35 +101,71 @@ export function slimCatalogPin(alias: string, image: string): SlimCatalogPin | u } /** - * Rewrites a docker.io image reference to its `ghcr.io/supabase/cli` slim - * equivalent, keeping the pin's version. This helper owns tag normalization - * (`v`-prefixing, `tagPrefix`), so pins that differ only in prefix between the - * two registries (`supavisor`, `logflare`) land on the right slim tag. Vector's - * docker.io tags carry an `-alpine` variant suffix that the slim build does - * not publish, so the strip is scoped to `vector` only — an `-alpine`-suffixed - * pin on any other service is a real tag, not a variant marker. + * Looks up the pinned catalog image (with its published `@sha256` digest) for + * `service` whose `upstreamVersion` equals `version`. Reads the same catalog + * `apps/cli`'s stack-independent clients use, keyed by the slim-services + * `sourceService` name (which matches this module's `SlimServiceName`). */ -export function toSlimImage(alias: string, image: string): string { +function catalogImageFor(service: SlimServiceName, upstreamVersion: string): string | undefined { + for (const entry of catalogPins()) { + if (entry.sourceService === service && entry.pin.upstreamVersion === upstreamVersion) { + return entry.pin.image; + } + } + return undefined; +} + +/** + * Resolves the catalog's pinned slim image (repository, release version and + * digest) whose `upstreamVersion` normalizes to `image`'s tag for `alias`. + * This owns tag normalization (`v`-prefixing, `tagPrefix`, vector's `-alpine` + * strip) via {@link slimCatalogPin}, so pins that differ only in prefix + * between the two registries (`supavisor`, `logflare`) still match. Returns `undefined` whenever + * no catalog pin matches `alias` and `image`'s tag — callers then keep the upstream (non-slim) + * image instead of guessing a slim tag. That covers more than "no slim build": an alias with no + * slim build at all (kong, `pg14`, the one-shot job images); an excluded tag on an alias that + * does have one (an OrioleDB `pg` tag, which {@link slimCatalogPin} always excludes); and a tag + * the catalog simply doesn't pin (an upstream version the catalog hasn't caught up to yet, or a + * hosted-project override from `supabase link` that doesn't match the pinned upstream version). + */ +export function toSlimImage(alias: string, image: string): string | undefined { const pin = slimCatalogPin(alias, image); if (pin === undefined) { - return image; + return undefined; } - return `${SLIM_IMAGE_PREFIX}${pin.service}:${pin.version}`; + return catalogImageFor(pin.service, pin.version); } /** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ export function slimImageForAlias(alias: string, image: string, enabled: boolean): string { - return enabled ? toSlimImage(alias, image) : image; + return enabled ? (toSlimImage(alias, image) ?? image) : image; } +/** The tag portion of `image`, ignoring any `@sha256:…` digest suffix. */ export function imageTag(image: string): string | undefined { - const tagSeparator = image.lastIndexOf(":"); - return tagSeparator === -1 ? undefined : image.slice(tagSeparator + 1); + const withoutDigest = image.split("@")[0] ?? image; + const tagSeparator = withoutDigest.lastIndexOf(":"); + return tagSeparator === -1 ? undefined : withoutDigest.slice(tagSeparator + 1); +} + +/** The repository portion of `image` (before the tag), the other half of `imageTag`'s split. */ +export function imageRepository(image: string): string | undefined { + const withoutDigest = image.split("@")[0] ?? image; + const tagSeparator = withoutDigest.lastIndexOf(":"); + return tagSeparator === -1 ? undefined : withoutDigest.slice(0, tagSeparator); +} + +/** The `@sha256:…` digest suffix of `image`, if it carries one. */ +export function imageDigest(image: string): string | undefined { + const at = image.indexOf("@"); + return at === -1 ? undefined : image.slice(at + 1); } -function replaceImageTag(image: string, tag: string): string { - const tagSeparator = image.lastIndexOf(":"); - return tagSeparator === -1 ? image : `${image.slice(0, tagSeparator + 1)}${tag}`; +/** Replaces `image`'s tag with `tag`, dropping any `@sha256:…` digest — a new tag invalidates it. */ +export function replaceImageTag(image: string, tag: string): string { + const withoutDigest = image.split("@")[0] ?? image; + const tagSeparator = withoutDigest.lastIndexOf(":"); + return tagSeparator === -1 ? image : `${withoutDigest.slice(0, tagSeparator + 1)}${tag}`; } /** @@ -167,7 +206,7 @@ export function slimImageForCurrentPin( if (trimmed.length > 0 && !pinMatchesCurrentImage(alias, trimmed, currentRawImage)) { return tagged; } - return toSlimImage(alias, tagged); + return toSlimImage(alias, tagged) ?? tagged; } /** Slim images are published only under this prefix; single home for the check. */ diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index 654ea9b1fd..5064bd026a 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -4,6 +4,7 @@ import { vi } from "vitest"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { + imageTag, pinMatchesCurrentImage, slimCatalogPin, slimImageForAlias, @@ -13,93 +14,171 @@ import { usesSlimImageRuntime, } from "./slim-images.ts"; -describe("toSlimImage", () => { +// Only `auth` is pinned in this fixture catalog, at `v2.197.0-r0`, with a realistic +// (non-placeholder) fixture digest built to the real `ArtifactPin`/`NativePin` shape from +// `@supabase/stack/internal/artifacts`. Every other service is deliberately absent, so +// `toSlimImage` falls through to the upstream image for them — the permanent state for a +// non-slim-capable alias (kong, `pg14`, the job images): the Dockerfile's slim-capable lines +// are generated from the catalog now, so they never disagree with it. `vi.mock` factories are +// hoisted above every other top-level statement, so the fixture is inlined rather than +// referencing an outer const. +vi.mock("@supabase/stack/internal/artifacts", () => { + const digest = "d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c"; + const nativePin = { archive: digest, manifest: digest }; + return { + catalogPins: () => [ + { + service: "auth", + sourceService: "auth", + pin: { + upstreamVersion: "v2.197.0", + revision: 0, + image: `ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:${digest}`, + natives: { + "darwin-arm64": nativePin, + "linux-amd64": nativePin, + "linux-arm64": nativePin, + }, + }, + }, + ], + }; +}); + +const AUTH_FIXTURE_PIN_IMAGE = + "ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("slimCatalogPin", () => { it.each([ - ["pg", "ghcr.io/supabase/cli/postgres"], - ["gotrue", "ghcr.io/supabase/cli/auth"], - ["postgrest", "ghcr.io/supabase/cli/postgrest"], - ["realtime", "ghcr.io/supabase/cli/realtime"], - ["storage", "ghcr.io/supabase/cli/storage"], - ["edgeruntime", "ghcr.io/supabase/cli/edge-runtime"], - ["studio", "ghcr.io/supabase/cli/studio"], - ["pgmeta", "ghcr.io/supabase/cli/pgmeta"], - ["logflare", "ghcr.io/supabase/cli/analytics"], - ["supavisor", "ghcr.io/supabase/cli/pooler"], - ["vector", "ghcr.io/supabase/cli/vector"], - ["imgproxy", "ghcr.io/supabase/cli/imgproxy"], - ["mailpit", "ghcr.io/supabase/cli/mailpit"], - ])("maps the %s manifest pin onto %s", (alias, repository) => { - const translated = toSlimImage(alias, dockerfileServiceImageRaw(alias)); - expect(translated.slice(0, translated.lastIndexOf(":"))).toBe(repository); - }); - - it("keeps a non-current pin instead of the catalog default", () => { - expect(toSlimImage("pg", "supabase/postgres:17.6.1.164")).toBe( - "ghcr.io/supabase/cli/postgres:17.6.1.164", - ); - expect(toSlimImage("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toBe( - "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", - ); + ["pg", "postgres"], + ["pg15", "postgres"], + ["gotrue", "auth"], + ["postgrest", "postgrest"], + ["realtime", "realtime"], + ["storage", "storage"], + ["edgeruntime", "edge-runtime"], + ["studio", "studio"], + ["pgmeta", "pgmeta"], + ["logflare", "analytics"], + ["supavisor", "pooler"], + ["vector", "vector"], + ["imgproxy", "imgproxy"], + ["mailpit", "mailpit"], + ])("maps the %s alias onto the %s slim service", (alias, service) => { + const pin = slimCatalogPin(alias, dockerfileServiceImageRaw(alias)); + expect(pin?.service).toBe(service); + }); + + it("keeps a non-current pin's version verbatim", () => { + expect(slimCatalogPin("pg", "supabase/postgres:17.6.1.164")).toEqual({ + service: "postgres", + version: "17.6.1.164", + }); + expect(slimCatalogPin("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toEqual({ + service: "studio", + version: "2026.08.17-sha-0c1da8f", + }); }); - // Fixed pins, not manifest pins: dependabot bumps the manifest, so spelling - // out a current pin here would fail on every bump. The `it.each` above covers - // the part that must track it (the repository each alias maps to). it("keeps a single v on pins already prefixed on docker.io", () => { - expect(toSlimImage("realtime", "supabase/realtime:v2.130.0")).toBe( - "ghcr.io/supabase/cli/realtime:v2.130.0", - ); - expect(toSlimImage("storage", "supabase/storage-api:v1.72.1")).toBe( - "ghcr.io/supabase/cli/storage:v1.72.1", - ); - expect(toSlimImage("gotrue", "supabase/gotrue:V2.196.0")).toBe( - "ghcr.io/supabase/cli/auth:v2.196.0", - ); + expect(slimCatalogPin("realtime", "supabase/realtime:v2.130.0")).toEqual({ + service: "realtime", + version: "v2.130.0", + }); + expect(slimCatalogPin("storage", "supabase/storage-api:v1.72.1")).toEqual({ + service: "storage", + version: "v1.72.1", + }); + expect(slimCatalogPin("gotrue", "supabase/gotrue:V2.196.0")).toEqual({ + service: "auth", + version: "v2.196.0", + }); }); it("v-prefixes pins whose slim tag scheme differs from docker.io's", () => { - expect(toSlimImage("supavisor", "supabase/supavisor:2.9.10")).toBe( - "ghcr.io/supabase/cli/pooler:v2.9.10", - ); - expect(toSlimImage("logflare", "supabase/logflare:1.50.4")).toBe( - "ghcr.io/supabase/cli/analytics:v1.50.4", - ); - expect(toSlimImage("pgmeta", "supabase/postgres-meta:v0.98.0")).toBe( - "ghcr.io/supabase/cli/pgmeta:v0.98.0", - ); + expect(slimCatalogPin("supavisor", "supabase/supavisor:2.9.10")).toEqual({ + service: "pooler", + version: "v2.9.10", + }); + expect(slimCatalogPin("logflare", "supabase/logflare:1.50.4")).toEqual({ + service: "analytics", + version: "v1.50.4", + }); + expect(slimCatalogPin("pgmeta", "supabase/postgres-meta:v0.98.0")).toEqual({ + service: "pgmeta", + version: "v0.98.0", + }); }); - it("keeps OrioleDB tags on docker.io", () => { - expect(toSlimImage("pg", "supabase/postgres:16.0.0.1-orioledb")).toBe( - "supabase/postgres:16.0.0.1-orioledb", - ); - expect(toSlimImage("pg", "supabase/postgres:orioledb-15.1.0.55")).toBe( - "supabase/postgres:orioledb-15.1.0.55", - ); + it("excludes OrioleDB tags", () => { expect(slimCatalogPin("pg", "supabase/postgres:16.0.0.1-orioledb")).toBeUndefined(); + expect(slimCatalogPin("pg", "supabase/postgres:orioledb-15.1.0.55")).toBeUndefined(); }); it("strips vector's docker.io -alpine variant suffix", () => { - expect(toSlimImage("vector", "timberio/vector:0.53.0-alpine")).toBe( - "ghcr.io/supabase/cli/vector:0.53.0", - ); + expect(slimCatalogPin("vector", "timberio/vector:0.53.0-alpine")).toEqual({ + service: "vector", + version: "0.53.0", + }); }); it("does not strip -alpine from a non-vector service's tag", () => { - expect(toSlimImage("studio", "supabase/studio:2026.08.17-alpine")).toBe( - "ghcr.io/supabase/cli/studio:2026.08.17-alpine", - ); + expect(slimCatalogPin("studio", "supabase/studio:2026.08.17-alpine")).toEqual({ + service: "studio", + version: "2026.08.17-alpine", + }); }); - it("passes through aliases with no slim build", () => { - for (const alias of ["kong", "differ", "migra", "pgprove"]) { - const image = dockerfileServiceImageRaw(alias); - expect(toSlimImage(alias, image)).toBe(image); + it("is absent for aliases with no slim build", () => { + for (const alias of ["kong", "pg14", "differ", "migra", "pgprove"]) { + expect(slimCatalogPin(alias, dockerfileServiceImageRaw(alias))).toBeUndefined(); } }); - it("passes through an untagged reference", () => { - expect(toSlimImage("pg", "supabase/postgres")).toBe("supabase/postgres"); + it("is absent for an untagged reference", () => { + expect(slimCatalogPin("pg", "supabase/postgres")).toBeUndefined(); + }); +}); + +describe("toSlimImage", () => { + it("returns the catalog's pinned image (with its digest) when the tag matches a catalog upstream version", () => { + expect(toSlimImage("gotrue", "supabase/gotrue:v2.197.0")).toBe(AUTH_FIXTURE_PIN_IMAGE); + // The alias-normalization prefix (V -> v) still applies before the catalog match. + expect(toSlimImage("gotrue", "supabase/gotrue:V2.197.0")).toBe(AUTH_FIXTURE_PIN_IMAGE); + }); + + it("returns undefined, keeping the upstream image, when the tag isn't in the catalog", () => { + expect(toSlimImage("gotrue", "supabase/gotrue:v2.100.0")).toBeUndefined(); + // `pg` has no entry at all in this fixture catalog. + expect(toSlimImage("pg", "supabase/postgres:17.6.1.164")).toBeUndefined(); + }); + + it("returns undefined for aliases and tags slimCatalogPin already excludes", () => { + expect(toSlimImage("pg", "supabase/postgres:16.0.0.1-orioledb")).toBeUndefined(); + expect(toSlimImage("kong", dockerfileServiceImageRaw("kong"))).toBeUndefined(); + expect(toSlimImage("pg", "supabase/postgres")).toBeUndefined(); + }); +}); + +describe("imageTag", () => { + it("returns the release tag, ignoring an @sha256 digest", () => { + expect( + imageTag( + "ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c", + ), + ).toBe("v2.197.0-r0"); + }); + + it("returns the tag on a plain (digest-less) reference", () => { + expect(imageTag("supabase/gotrue:v2.197.0")).toBe("v2.197.0"); + }); + + it("returns undefined on an untagged reference", () => { + expect(imageTag("supabase/postgres")).toBeUndefined(); }); }); @@ -144,14 +223,20 @@ describe("slimImagesEnabled", () => { describe("slimImageForAlias", () => { it("is a no-op while the flag is off", () => { - expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", false)).toBe( - "supabase/postgres:17.6.1.165", + expect(slimImageForAlias("gotrue", "supabase/gotrue:v2.197.0", false)).toBe( + "supabase/gotrue:v2.197.0", + ); + }); + + it("translates when the flag is on and the tag matches the catalog", () => { + expect(slimImageForAlias("gotrue", "supabase/gotrue:v2.197.0", true)).toBe( + AUTH_FIXTURE_PIN_IMAGE, ); }); - it("translates when the flag is on", () => { + it("keeps the upstream image when the flag is on but the tag isn't in the catalog", () => { expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", true)).toBe( - "ghcr.io/supabase/cli/postgres:17.6.1.165", + "supabase/postgres:17.6.1.165", ); }); }); @@ -179,24 +264,30 @@ describe("pinMatchesCurrentImage", () => { }); describe("slimImageForCurrentPin", () => { - it("slim-translates the current pin and leaves a historical pin on docker.io", () => { - const current = dockerfileServiceImageRaw("storage"); - const currentTag = current.split(":")[1] ?? ""; - expect(slimImageForCurrentPin("storage", current, undefined, true)).toBe( - toSlimImage("storage", current), - ); - expect(slimImageForCurrentPin("storage", current, currentTag, true)).toBe( - toSlimImage("storage", current), + it("slim-translates the current pin using the catalog's pinned digest", () => { + const current = "supabase/gotrue:v2.197.0"; + expect(slimImageForCurrentPin("gotrue", current, undefined, true)).toBe(AUTH_FIXTURE_PIN_IMAGE); + expect(slimImageForCurrentPin("gotrue", current, "v2.197.0", true)).toBe( + AUTH_FIXTURE_PIN_IMAGE, ); - expect(slimImageForCurrentPin("storage", current, "v1.67.0", true)).toBe( - "supabase/storage-api:v1.67.0", + expect(slimImageForCurrentPin("gotrue", current, "v1.67.0", true)).toBe( + "supabase/gotrue:v1.67.0", ); }); it("is a no-op while the flag is off", () => { - const current = dockerfileServiceImageRaw("storage"); - expect(slimImageForCurrentPin("storage", current, "v1.67.0", false)).toBe( - "supabase/storage-api:v1.67.0", + const current = "supabase/gotrue:v2.197.0"; + expect(slimImageForCurrentPin("gotrue", current, "v1.67.0", false)).toBe( + "supabase/gotrue:v1.67.0", + ); + }); + + it("falls back to the upstream image on the linked-pin fallback path (a hosted version that doesn't match the pin)", () => { + const current = "supabase/gotrue:v2.197.0"; + // The linked project's hosted version (v1.67.0) differs from the catalog's pinned + // upstream version (v2.197.0): stay on the upstream (non-slim) image instead of guessing. + expect(slimImageForCurrentPin("gotrue", current, "v1.67.0", true)).toBe( + "supabase/gotrue:v1.67.0", ); }); }); diff --git a/apps/cli/tests/helpers/slim-images.ts b/apps/cli/tests/helpers/slim-images.ts new file mode 100644 index 0000000000..0bab6745fc --- /dev/null +++ b/apps/cli/tests/helpers/slim-images.ts @@ -0,0 +1,29 @@ +import { catalogPins } from "@supabase/stack/internal/artifacts"; + +import { slimCatalogPin } from "../../src/shared/services/slim-images.ts"; + +/** A regression to the docker.io fallback must fail an assertion built from this. */ +export const GHCR_SLIM_IMAGE_PATTERN = /^ghcr\.io\/supabase\/cli\/.+@sha256:[0-9a-f]{64}$/; + +/** + * The catalog's own pinned image for `alias`'s (docker.io) `image` — read straight from + * `catalogPins()`, independent of `toSlimImage`, so a test asserting against this actually + * exercises the catalog lookup instead of passing whether or not it resolves (a default + * Dockerfile tag is generated from the catalog, so it always matches a catalog pin). Only reuses `slimCatalogPin` for alias + * and tag normalization (`v`-prefixing), not the catalog image lookup itself. Throws when + * nothing is pinned, so a caller never silently falls back to a weaker assertion. + */ +export function expectedPinnedImage(alias: string, image: string): string { + const pin = slimCatalogPin(alias, image); + if (pin === undefined) { + throw new Error(`no slim catalog pin for ${alias} ${image}`); + } + const entry = catalogPins().find( + (candidate) => + candidate.sourceService === pin.service && candidate.pin.upstreamVersion === pin.version, + ); + if (entry === undefined) { + throw new Error(`no catalog pin for ${pin.service} ${pin.version}`); + } + return entry.pin.image; +} diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index cdcc4fb4ca..cba056ffe3 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -1,48 +1,191 @@ # 0026. Slim image and native artifact mirrors -**Status**: proposed -**Date**: 2026-09-17 +**Status**: accepted +**Date**: 2026-09-29 ## Problem Statement -Slim service images publish to `ghcr.io/supabase/cli/<service>:<version>`. Native archives publish to GitHub Releases. A broken build must be replaceable under the **same** upstream version string (`force=true`); bumping the tag is not an option. +slim-services used to publish each artifact under the exact upstream version string, so the +only way to fix bad packaging was `force=true`: overwrite the GitHub release assets +(`--clobber`), move the GHCR/ECR tags, and overwrite the S3 objects. That broke already-released +CLIs pinned to that version, and the CLI itself resolved artifacts inconsistently: some catalog +images were pinned by `@sha256` and some were tag-only, native archives had no pin at all, and +`manifest.json` was not integrity-checked despite supplying `entrypoint`/`cmd`. The runtime +checksum came from mutable sources — the release `SHA256SUMS` or a GHCR `:<v>-native-<target>` +tag — so one CLI version could run different bytes on different machines. -Some consumers cannot reach GitHub release assets or a single registry blob CDN. This ADR covers publishing those copies and the local stack's fail-through. +Some consumers also cannot reach GitHub release assets or a single registry blob CDN. This ADR +covers the immutable publish scheme, the CLI's content pins, and the local stack's fail-through +across mirrors. ## Decision -Publish each slim **image** to GHCR and AWS ECR Public (`public.ecr.aws/supabase/cli/<service>`), digest-preserving, via `mirror-slim-image.yml` and `.github/scripts/mirror-slim-image.ts`. Publish each slim **native** archive as an OCI artifact on the same two repositories under `:version-native-<target>` (not `:version-linux-*`, which are image platform tags). GitHub Releases remain the human HTTPS copy and `--clobber` on force. - -`force=true` always requests the ECR copy, including when the image digest is unchanged (natives may have moved). The **image** destination digest gates `publish-release` once the dispatch token exists. Native ECR copy is best-effort (`continue-on-error`) and must not fail that release. Native tags ride in a separate `natives[]` payload field. This mirror does not open a pull request to pin `packages/stack/src/Artifacts.ts`. A Dependabot Dockerfile bump commits that catalog pin onto the same pull request, so the docker.io tag, the slim image, and the native archive stay on one version. A PostgreSQL release line that is not the Dockerfile `pg` tag, and a same-version digest republish, stay put until that Dockerfile line changes. Do not prune untagged GHCR or ECR manifests: already-shipped CLIs still pin old image digests until that pull request merges. Natives follow the moved tag immediately. - -ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`. - -A public S3 bucket on `*.amazonaws.com` holds native archives for hosts that cannot reach GitHub Releases ([infra/cli-artifacts](../../infra/cli-artifacts/README.md)). It is not a checksum authority. The stack tries the GitHub Release first and falls back to that bucket. The expected checksum comes from the release `SHA256SUMS` or, when that is blocked, from the archive layer of the `:version-native-<target>` artifact on GHCR. An archive from either host is accepted only when it matches. - -The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing image fallback still reports the primary's original error. When every native checksum source or archive mirror fails, the error names each source with its failure. +### Immutable revisions + +Every slim-services publish carries a release version `R = <U>-r<N>`, where `U` is the upstream +tag exactly as the service's `tag_pattern` matches it and `N` is a revision starting at `0`. +Revision `N` of `U` is taken exactly when the GitHub release `<svc>-U-rN` exists — the GitHub +release is the commit point. Allocation is `max(taken) + 1`, computed from the full paginated +release list before any push. Once a release exists, `gh release create` is create-only: nothing +ever writes to that `-rN` again, so **there is no same-version overwrite**. A hotfix publishes a +new `-rN`; it never touches a previously published revision's bytes. `R` is never fed to a +semver library — ordering is the service's upstream version key, then revision, numerically. + +**Legacy tags** (without `-rN`) are frozen: nothing parses, audits, rewrites, or unpublishes +them, so CLIs pinned to a legacy tag keep working. + +### The catalog is the single version table + +The CLI's stack catalog (`packages/stack/src/Artifacts.ts`) is the single version table for +every consumer of a slim-capable service: the new stack, legacy `supabase start`, and legacy +slim mode. Each slim-capable service pins exactly one committed slim-services release per +release line it carries (`ArtifactPin`, keyed by upstream version; only postgres carries more +than one line, its default plus an additional Postgres 15 pin). Everything else is derived from +that pin — never the other way around. + +`apps/cli/src/shared/services/Dockerfile`, and its byte-identical Go copy +(`apps/cli-go/pkg/config/templates/Dockerfile`), keep every existing consumer (the legacy TS +stack, the Go CLI embed, `mirror-template-images.yml`, `detect-unmirrored-images.ts`) unchanged, +but for a slim-capable alias its `FROM` line is now a **generated view** of the catalog's +`ArtifactPin.upstreamImage`: `apps/cli/scripts/render-service-dockerfile.ts` rewrites those lines +in place, and a CI check (`render-service-dockerfile.unit.test.ts`) fails on drift between the +Dockerfile and the catalog. Kong and Postgres 14 have no slim build and are bumped by hand (both +share `library/kong`'s or `supabase/postgres`'s Dependabot `ignore` entry — see "Tradeoffs" below). +The one-shot job images (migra, pg_prove, pgadmin-schema-diff) also have no slim build, but are +upstream-only: they are the images Dependabot still bumps. + +### Content pins, not runtime checksums + +Every `ArtifactPin` pins its release by content: + +- the slim image, as `ghcr.io/supabase/cli/<service>:<R>@sha256:<digest>`; +- each target's archive sha256 and manifest sha256 (`ArtifactPin.natives`); +- `upstreamImage`, the exact upstream image the release was built or mirrored from, normalized to + the Dockerfile's `FROM` form — this is what legacy non-slim mode resolves against, and what the + generator reads to render the Dockerfile. + +`SlimServicesSource` hash-checks the manifest against its pin before parsing it (its `version` +field must equal `R`) and hash-checks the archive against its pin while it streams. There is no +runtime checksum authority: **GitHub Releases, the S3 mirror, GHCR, and ECR Public are byte +mirrors only** for both images and native archives. None of them is consulted for the expected +hash — that comes only from the pin already committed to the catalog. + +### Sync and the S3-staleness check + +`.github/scripts/sync-artifacts-catalog.ts` writes those pins, in manual mode: given a service +and either `--upstream` (the highest committed revision of that upstream version) or `--release` +(exactly that committed `<upstream>-r<N>`, never "highest at apply time"), it reads the target +release's `SHA256SUMS` for the archive and manifest sha256 per target, and resolves the image +digest with `regctl manifest head`. Before writing the pin, it downloads each target's S3 archive +and manifest and hashes them against those same release sums. This exists because +`publish-release` does not wait for the ECR/S3 mirror to finish, so a freshly committed +revision's S3 copy can briefly lag. A missing object waits, bounded (`waitForExpectedRelease`'s +retry helper, generalized); an object that exists with the wrong bytes fails the sync +immediately — that's corruption, not lag, and means "run the mirror backfill", not "the CLI is +broken". Hosts that reach GitHub are unaffected — GitHub is the primary mirror — but a host that +can only reach S3 would otherwise fail verification with no fallback. + +### Hotfix and upgrade pickup + +The last step of slim-services' `publish-release` sends a `repository_dispatch` +(`slim-release-published`) to the CLI repo with `{service, upstream_version, revision, +release_version}`. `slim-release-published.yml` treats the dispatch as a trigger, not as the +payload to apply: it reconciles the named service against every committed (published, non-draft) +`<service>-...-r<N>` release it can currently see (`planSlimUpdates`), and opens or updates, per +release line the service carries: + +- a **hotfix**, when the pinned upstream version has a higher committed revision — branch + `slim-hotfix/<svc>[-<line>]`, title `chore(stack): pin <svc> <release_version>`; +- an **upgrade**, when the newest committed upstream on that line is newer than the pinned one — + branch `slim-bump/<svc>[-<line>]`, title `chore(stack): bump <svc> to <release_version>`. + +Both can be planned in the same run. Plan and apply run from the same checkout of the default +branch in one job, so a re-run always recomputes from the latest develop: a stale plan can never +be applied, and a superseded PR's branch is rewritten (force-pushed) in place rather than raced +by a new one — the workflow deliberately never auto-closes a superseded PR. A backlog republish +of an older upstream version naturally plans nothing. The fallback, if the push or PR step fails, +is a documented manual `bun .github/scripts/sync-artifacts-catalog.ts --service <svc> --release +<U>-r<N>` invocation, followed by `apps/cli/scripts/render-service-dockerfile.ts` — the release +itself is already committed by then, so a failure here means "open the pull request by hand", not +"republish". + +### Registry and bucket mirrors + +Publish each slim **image** to GHCR and AWS ECR Public (`public.ecr.aws/supabase/cli/<service>`), +digest-preserving, via `mirror-slim-image.yml` and `.github/scripts/mirror-slim-image.ts`. +Publish each slim **native** archive as an OCI artifact on the same two repositories under +`:R-native-<target>`. A public S3 bucket on `*.amazonaws.com` +([infra/cli-artifacts](../../infra/cli-artifacts/README.md)) holds native archives for hosts that +cannot reach GitHub Releases. The container runtime pulls a catalog image from GHCR first and +falls back to the same reference on ECR Public. A failing image fallback still reports the +primary's original error. When every native archive mirror fails to serve the pinned bytes, the +error names each mirror with its failure. + +ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. +Mirror backfills of a committed revision copy the immutable GHCR bytes by digest, so re-running +one is idempotent. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`. ## Follow-up -- Native ECR copy is best-effort; a daily mirror audit should report native drift. That audit is not defined in this repository yet. +- Native ECR copy is best-effort; a daily mirror audit should report native drift and treat the + committed GHCR digest as the source of truth for backfills. That audit is not defined in this + repository yet. +- Unpublishing legacy (non-revision) artifacts is planned as a separate task; they stay published + and frozen until then. ## Rationale -ECR Public mirroring already exists for other CLI images and needs no new vendor. Native OCI on those same repos reuses `regctl` and that role. Extracting the workflow into bun scripts lets the copy, digest check, and native payload validation run in CI and locally. +ECR Public mirroring already exists for other CLI images and needs no new vendor. Native OCI on +those same repos reuses `regctl` and that role. Making the GitHub release the single commit +point, rather than a destination-specific immutability guard on each mirror, keeps the +invariant in one place: once `<svc>-U-rN` exists, every downstream copy of it is either correct +or considered stale and backfilled — never rewritten in place. ## Consequences -- Same-version overwrite works on GHCR, GitHub Releases, and ECR Public. -- Images come from the GHCR catalog pin with ECR Public as fallback, and natives from GitHub Releases with the S3 bucket as fallback. -- A GitHub native can be live while the ECR native is stale. -- Old CLI releases keep pulling the previous image digest. +- Hotfixing packaging never touches previously published bytes; it always publishes a new + revision. +- The CLI verifies every artifact it downloads against a pin already committed to its own + catalog — no destination is trusted to supply the expected hash at runtime. +- A same-version republish is no longer possible; every fix is a new, allocatable revision. +- A revision's ECR/S3 copy can briefly lag GitHub after publish; the sync's S3-staleness check + and the mirror backfill are what keep them converging. +- Old CLI releases keep pulling their originally pinned image digest and native bytes forever. + +### Tradeoffs of moving updates onto the catalog + +- Upstream and security fixes for slim-capable images now reach the CLI only once + slim-services publishes a release. Direct Dependabot discovery is gone for them; Dependabot's + `docker` ecosystem `ignore` list (`.github/dependabot.yml`) now excludes every slim-capable + image plus `library/kong` and `supabase/postgres`, so it bumps only the three images that + remain genuinely upstream-only: migra, pg_prove, pgadmin-schema-diff. +- Dependabot's 7-day cooldown no longer governs the excluded images — hotfix and upgrade PRs for + slim-capable ones land as soon as `slim-release-published` fires, on whatever cadence + slim-services publishes; kong and `pg14` are simply bumped by hand. +- `pg14` has no slim build and was already upstream-only; it is bumped by hand alongside kong, + not by Dependabot — Dependabot's `docker` ecosystem ignores `supabase/postgres` entirely now, + since it cannot tell `pg14`'s `FROM` line apart from `pg`'s and `pg15`'s by repository name + alone. +- The Deno 1 edge-runtime override (`apps/cli/src/shared/functions/functions.shared.ts`, and the + Go `deno1` constant) stays a separately pinned, upstream-only exception — it never goes through + the catalog. ## Alternatives considered -1. **Google Artifact Registry / GCS** — blob host `storage.googleapis.com` is on at least one major sandbox Trusted list. Rejected for this cut: new company-wide vendor. -2. **Public S3 bucket** — HTTPS GET on `*.amazonaws.com` can work without CloudFront. Adopted for native archives. The bucket is not a checksum authority, and image pulls stay on GHCR and ECR Public. +1. **Google Artifact Registry / GCS** — blob host `storage.googleapis.com` is on at least one + major sandbox Trusted list. Rejected for this cut: new company-wide vendor. +2. **Public S3 bucket** — HTTPS GET on `*.amazonaws.com` can work without CloudFront. Adopted for + native archives, as a byte mirror only; the CLI's checksum authority is its own catalog pin, + verified against the release's `SHA256SUMS` once at sync time, not read at runtime. 3. **npm packages** — allowlisted widely, but a published version cannot be replaced. -4. **Docker Hub `supabase/cli-*`** — deferred; blob CDN is also off some default allowlists, and names collide with upstream `supabase/<service>`. -5. **Unpin slim images** — would make old CLIs see a moved tag. Rejected: conflicts with ADR 0017. +4. **Docker Hub `supabase/cli-*`** — deferred; blob CDN is also off some default allowlists, and + names collide with upstream `supabase/<service>`. +5. **Same-version overwrite (`force=true` clobber)** — the original model. Rejected: it broke + already-released CLIs pinned to that version and made a republish silently move bytes out + from under them. Replaced by immutable `-rN` revisions. +6. **A destination-specific immutability guard (for example, an S3 conditional write)** — + rejected in favor of making the GitHub release the single commit point; every other + destination is just a copy that either matches it or is stale. ## Related diff --git a/infra/cli-artifacts/README.md b/infra/cli-artifacts/README.md index 9413175da1..07f0b01708 100644 --- a/infra/cli-artifacts/README.md +++ b/infra/cli-artifacts/README.md @@ -18,13 +18,22 @@ agent sandboxes that allow `*.amazonaws.com`. Objects are addressed as ``` -https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<version>/<service>-<version>-<target>.tar.zst -https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<version>/<service>-<version>-<target>.manifest.json -https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<version>/<service>-<version>-<target>.SHA256SUMS +https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<release-version>/<service>-<release-version>-<target>.tar.zst +https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<release-version>/<service>-<release-version>-<target>.manifest.json +https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<release-version>/<service>-<release-version>-<target>.SHA256SUMS ``` -which keeps the GitHub release asset names, so the `SHA256SUMS` lines match the archive name -without rewriting. +where `<release-version>` is the immutable `<upstream>-r<N>` slim-services revision, which keeps +the GitHub release asset names so the `SHA256SUMS` lines match the archive name without +rewriting. + +This bucket, like GHCR and ECR, is a byte mirror only — it is never a checksum authority. The +CLI does not fetch `SHA256SUMS` (from here or from GitHub) at runtime: it pins each target's +archive and manifest sha256 directly in `packages/stack/src/Artifacts.ts`, written by +`.github/scripts/sync-artifacts-catalog.ts` after that script has already verified this bucket's +copy against the release's committed `SHA256SUMS` (see +[ADR 0026](../../docs/adr/0026-slim-artifact-mirrors.md)). A stale object here fails that sync, +not a CLI download. ## How it deploys diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 0cd6c8815a..4ffbe1290d 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -30,10 +30,42 @@ export class ArtifactError extends Data.TaggedError("ArtifactError")<{ readonly cause?: unknown; }> {} +/** Lowercase hexadecimal SHA-256 digest. */ +type Sha256 = string; + +/** Content digests of one native target's published archive and manifest. */ +export interface NativePin { + readonly archive: Sha256; + readonly manifest: Sha256; +} + +/** One published slim-services revision of an upstream version, pinned by content. */ +export interface ArtifactPin { + readonly upstreamVersion: string; + readonly revision: number; + /** `ghcr.io/supabase/cli/<service>:<release version>@sha256:<digest>`. */ + readonly image: string; + /** + * The exact upstream image this release was built or mirrored from, as slim-services recorded + * it: the release manifest's `upstream_image` for a derived service, or the release's + * `oci-provenance.json` `source` for a mirrored one (vector, mailpit, imgproxy). Normalized to + * the Dockerfile's `FROM` form — no leading `docker.io/`, no digest — so legacy non-slim mode + * can use it as the upstream tag directly. + */ + readonly upstreamImage: string; + readonly natives: Readonly<Record<NativeTarget, NativePin>>; +} + +/** The published release version, `<upstream>-r<revision>`. */ +const releaseVersion = (pin: ArtifactPin): string => `${pin.upstreamVersion}-r${pin.revision}`; + interface ArtifactResolution { readonly service: ServiceKind; + /** Upstream version. */ readonly version: string; + readonly releaseVersion: string; readonly image: string; + readonly natives: ArtifactPin["natives"]; readonly executablePath: string; readonly requiredRuntimePaths: ReadonlyArray<string>; } @@ -48,94 +80,380 @@ export interface PreparedNativeArtifact { interface ArtifactDefinition { readonly sourceService: string; readonly defaultVersion: string; - readonly images: Readonly<Record<string, string>>; + /** Pins keyed by upstream version. */ + readonly pins: Readonly<Record<string, ArtifactPin>>; readonly requiredRuntimePaths: ReadonlyArray<string>; readonly executablePath: string; } const definition = ( sourceService: string, - defaultVersion: string, - image: string, + pin: ArtifactPin, executablePath: string, requiredRuntimePaths: ReadonlyArray<string> = [executablePath], - additionalImages: Readonly<Record<string, string>> = {}, + additionalPins: Readonly<Record<string, ArtifactPin>> = {}, ): ArtifactDefinition => ({ sourceService, - defaultVersion, - images: { [defaultVersion]: image, ...additionalImages }, + defaultVersion: pin.upstreamVersion, + pins: { [pin.upstreamVersion]: pin, ...additionalPins }, requiredRuntimePaths, executablePath, }); +const SLIM_IMAGE_GHCR_REGISTRY = "ghcr.io/supabase/cli/"; + const definitions: Readonly<Record<ServiceKind, ArtifactDefinition>> = { database: definition( "postgres", - "17.6.1.173", - "ghcr.io/supabase/cli/postgres:17.6.1.173@sha256:9d6e542382946cad5eb1f11f1c8108a51297902ee42fe5098358816d3784ba5a", + { + upstreamVersion: "17.11.0.002", + revision: 0, + image: + "ghcr.io/supabase/cli/postgres:17.11.0.002-r0@sha256:5a551a204a41267c4f78a2e5b34657f5c6fdd39eddccd50262466481b95739cc", + upstreamImage: "supabase/postgres:17.11.0.002", + natives: { + "darwin-arm64": { + archive: "10410e402c77210a0543539d9bafcabd0c04923d8e796928cbd2a053cf7b4f4f", + manifest: "6c1f236324f02baf472152aafed68884cb115b3979fbf1c49209717485d36ac2", + }, + "linux-amd64": { + archive: "4a4410791bdeeda2e08fda400039b4319bb26d74e08e68951f38abc9dacd7c26", + manifest: "cd76249031db380831d773acea6fea8a54104d5ca97bb18db34d05e7bd124e5f", + }, + "linux-arm64": { + archive: "1d54954b2441990643f25a825e4e46ec50ecf538a63baa8e413e353e7faeb939", + manifest: "8dda36ffdb7b5b501873ff41c265bb061e8eb9aeed0e4372c5ad9af59110776b", + }, + }, + }, "bin/supabase-postgres-start", ["bin/supabase-postgres-start", "bin/pg_dump", "bin/pg_dumpall", "bin/pg_prove", "bin/psql"], { - "15.14.1.173": - "ghcr.io/supabase/cli/postgres:15.14.1.173@sha256:3abb20e89700d6211e741148b85c3a052bb290cae3b4f751311442f4a5fe2324", + "15.19.0.002": { + upstreamVersion: "15.19.0.002", + revision: 0, + image: + "ghcr.io/supabase/cli/postgres:15.19.0.002-r0@sha256:a3f343f19323497a5766fa4e86a51dba495a8eca354cc128d879b4632c6bd017", + upstreamImage: "supabase/postgres:15.19.0.002", + natives: { + "darwin-arm64": { + archive: "59b05ca76db9d807803840264d885a5ff435e552f294d7f22ca831fac1ffb4a3", + manifest: "b7956520ee15a8265635cabb318490f89c4737706f9349246da9921a17fbac07", + }, + "linux-amd64": { + archive: "bdf565e0b866ca7b2494b56b502963ef57d54ab1c99eac6855f760caaffda712", + manifest: "e1ecda082f1c414fea6356d0399eaf76d22a25b96808961a945aacb242f904c9", + }, + "linux-arm64": { + archive: "c7420f0eb0938397d90906265630359313936cc576035cb31c2bdef6451565c6", + manifest: "207feac260bcbc5607decca9e14e0422accc4a0ae1f7d24b8fe0992d284cc43c", + }, + }, + }, + }, + ), + rest: definition( + "postgrest", + { + upstreamVersion: "v16.4", + revision: 0, + image: + "ghcr.io/supabase/cli/postgrest:v16.4-r0@sha256:63a8d4acfdeb107b6568f4582759c78072100ef07951a7fbe58c9a51241138a7", + upstreamImage: "postgrest/postgrest:v16.4", + natives: { + "darwin-arm64": { + archive: "a1f5449d739404cbd042ec9dbabadea6dcf810db636e44c7176b859d5e78ab07", + manifest: "943cbeb7a3cf2f9dd0406152a178d814e81221d0061626b04ab2b042209d5e03", + }, + "linux-amd64": { + archive: "d9170378062dba1188c25fb05cd08a3397bb62afd63151812f831eed4fab1051", + manifest: "a3f3f29c4c4c5f7b004b507152f29316bc79df052652fb00ad5ad9bdd6f7be29", + }, + "linux-arm64": { + archive: "c1a99eddaac0c005b2520960d258c7fc79945ba0703eff3a2515aa119fac4477", + manifest: "7ced80e3f814d9748763983de74706c6e04a897f8c024366af156c8093cf150f", + }, + }, + }, + "bin/postgrest", + ), + auth: definition( + "auth", + { + upstreamVersion: "v2.197.0", + revision: 0, + image: + "ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:7eb303831d170865840dbb3f9018b48561c988704ad1d9a2bafabb4e93e92da5", + upstreamImage: "supabase/gotrue:v2.197.0", + natives: { + "darwin-arm64": { + archive: "eb365c4aea1e1cd04998ed16cd49b1b90e6a097ce633e5ee55f8bbbebdd349c7", + manifest: "178f748da1c886b07945a67a839d3a17fa546e01215a90329ded1a634831deb6", + }, + "linux-amd64": { + archive: "bd8f59ed1a817014afe71288c2275ad0396630f016f8cb5ab5129388860ec49b", + manifest: "ccad45ee54edf7506b055b85dcebee6db8c5b9ec57ac3c6d30baf38bb81b1deb", + }, + "linux-arm64": { + archive: "9598a8dd5a08707f65795505211c479b686c54e3dadbe64b3e1989855215cf0c", + manifest: "4f5f3a6284f0b161cf49a201fe551470d5b6e0e2b37dedb4dacdd0e42abaf3b1", + }, + }, }, + "bin/auth", ), - rest: definition("postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest"), - auth: definition("auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth"), realtime: definition( "realtime", - "v2.134.5", - "ghcr.io/supabase/cli/realtime:v2.134.5@sha256:ee672ffd06ca0a1712a06aea1307c134c366ec082a51b051a8d59a8ad0c72755", + { + upstreamVersion: "v2.140.3", + revision: 0, + image: + "ghcr.io/supabase/cli/realtime:v2.140.3-r0@sha256:af7b883647770351eef150cc16e1f12d7fe4b4920324e2e40487961ad3b22789", + upstreamImage: "supabase/realtime:v2.140.3", + natives: { + "darwin-arm64": { + archive: "e015ced15ed2110c2a0123b6e75edb2aeb12ac6bdbacffdfb296262a4215f97e", + manifest: "a833a92e7b2e34b6bfcf41c4c934587f85e96eb11acb9cce14852b8ffec312d6", + }, + "linux-amd64": { + archive: "d528a255b2cb20df25ab9440e29f026e2fb3c993e4ed7b0707e037d0cb3880d9", + manifest: "fc5445d4b28d41691091c8cdccde7cff0dbd06af2bf56e70401ec6a049fb8757", + }, + "linux-arm64": { + archive: "bb3b810540dbba2f9a94eb974799384cbffe67756cd91ebb3b8254d02d890e2e", + manifest: "1a33f5620a39a64b49645c25878a892122d695f8f1a26d3548fb5332d6e0d88b", + }, + }, + }, "bin/server", ["bin/server", "bin/prepare"], ), storage: definition( "storage", - "v1.79.28-r1", - "ghcr.io/supabase/cli/storage:v1.79.28-r1@sha256:95e0007f273e7c990ab81c44e021e4278b8953dd95ae2fbdc19fca047d4bd470", + { + upstreamVersion: "v1.79.28", + revision: 1, + image: + "ghcr.io/supabase/cli/storage:v1.79.28-r1@sha256:95e0007f273e7c990ab81c44e021e4278b8953dd95ae2fbdc19fca047d4bd470", + upstreamImage: "supabase/storage-api:v1.79.28", + natives: { + "darwin-arm64": { + archive: "bea019baff21e10b78291a9c23687ec15ebb45994d0d03c656ffda13e13108d4", + manifest: "8612ea30b918ac6590f30660b518ea41b80d9d591734fa3313a64c6607621cb2", + }, + "linux-amd64": { + archive: "c8ce124acfe46a2151052f160a81653f751277516f5c378d93ba7f18e7b3efe4", + manifest: "edce6bb24a84e5245956c1de3fa93d96d3813efbe2e5dac105b385ebae7e3f0d", + }, + "linux-arm64": { + archive: "8a51c2b8bc4d1076665321ae0da7eca81dbdff84dbc687a209a958955cf9cb69", + manifest: "befaaec0e8660d99a4040f81ece4277e4db27812b8e2630a4d0b15d16b3ed831", + }, + }, + }, "bin/storage", ["bin/storage", "bin/prepare"], ), imgproxy: definition( "imgproxy", - "v3.8.0", - "ghcr.io/supabase/cli/imgproxy:v3.8.0", + { + upstreamVersion: "v3.26.0", + revision: 0, + image: + "ghcr.io/supabase/cli/imgproxy:v3.26.0-r0@sha256:5871582cf6c5140d3b50b21e0d1aa236fdf46ee6133e3bfec1baf63545e01cf0", + upstreamImage: "ghcr.io/imgproxy/imgproxy:v3.26.0", + natives: { + "darwin-arm64": { + archive: "1f82084b056bf4806bf492bca0b6f963068d07a6776a2a122b61da940732683b", + manifest: "53b5c4f4f069db88c3919571dbee12af2373e5c90db291cf5d7456aa74e17ecb", + }, + "linux-amd64": { + archive: "7865edf054b8217c2daf9d42bce6bebe34f83df5c4183fbbe6495a3603dcd4a8", + manifest: "5ae7f22a6fbdc55f41ffc3334747a3475295676699ac1d2c372bc5fe2ad239bb", + }, + "linux-arm64": { + archive: "4528fd7877c9df3052cd6ec06c8cafd7e92977dd26490fb532964c19faec3b0a", + manifest: "5420e6d9b3661060b2dedfe157c18b56f22011c43cc3127031b4bbc65e936b59", + }, + }, + }, "bin/imgproxy", ), functions: definition( "edge-runtime", - "v1.77.1", - "ghcr.io/supabase/cli/edge-runtime:v1.77.1@sha256:db55555ba640180671be297179797ea39c8c6cf09a22a0b883923cb86938f5e1", + { + upstreamVersion: "v1.77.1", + revision: 0, + image: + "ghcr.io/supabase/cli/edge-runtime:v1.77.1-r0@sha256:98582ce39914bba6ac856a5bf6ed3c1584ce4616687ca359c8acd5f59dd9ff59", + upstreamImage: "supabase/edge-runtime:v1.77.1", + natives: { + "darwin-arm64": { + archive: "e52fdbedccf258fd9a427b19bd34569ca210c1ca257a85fc23f66b4e18c1d6a1", + manifest: "ef2590d8119fe1c2c877746ef72f58ed4d935ce23647b893fd5d36892b0496e0", + }, + "linux-amd64": { + archive: "e524630f0743319d77535f93748a894520fd52cf9c8fa8c2adb22e08ddb39419", + manifest: "3ff18dc0667373dfc108da7d206dbfbf844f2596a804319343d5793298416e9c", + }, + "linux-arm64": { + archive: "c0fe416e506087afd75771beeeb6cfd070d4d6056293a940e3bf453637ba7a5f", + manifest: "703581ebe6213c484955313fbec3ea6ad5f20a86f0c48eb0e29d1bbe9d1c286b", + }, + }, + }, "bin/edge-runtime", ), studio: definition( "studio", - "2026.09.04-sha-5a67366", - "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366@sha256:9823a31668028f1846e87331bc21598d9cd74bcaa1466c72dab58c33c9c82720", + { + upstreamVersion: "2026.09.28-sha-5e59b60", + revision: 0, + image: + "ghcr.io/supabase/cli/studio:2026.09.28-sha-5e59b60-r0@sha256:4cf4f70978bb0866d1644b43cb0d23acc4b4ef7a898592043ad0d13cad8059be", + upstreamImage: "supabase/studio:2026.09.28-sha-5e59b60", + natives: { + "darwin-arm64": { + archive: "37f420f6af3d5ee7dab884e8c39fe2c3bcddcee0d90e53d1e075a05ae3cb4b73", + manifest: "b5d6209c4c28e594cc8b0ab961105465418f0021ba50a3fd92067ff5720ae820", + }, + "linux-amd64": { + archive: "19a9903732b71fba04ce342e4d13b83bf7579bce806981cf8f2b491ad792fc6e", + manifest: "5a4f1e316ad84ff058263601b9b144177ab0cd18443e826354551aec88013199", + }, + "linux-arm64": { + archive: "ce69544c9ec5dbae50e5da731770bc199e12f0f1d63b22ec7a43b6a0ba00cb4c", + manifest: "c27374ccc51cc6419f9bbd14f095f1a16db4bda369928effcc15a6366dcb3d1f", + }, + }, + }, "bin/studio", ), pgmeta: definition( "pgmeta", - "v0.99.0", - "ghcr.io/supabase/cli/pgmeta:v0.99.0@sha256:90de2dcf03ac548ae2d1d3e71b3cd10bde4c627572720a42e4c3946b7090292e", + { + upstreamVersion: "v0.99.0", + revision: 0, + image: + "ghcr.io/supabase/cli/pgmeta:v0.99.0-r0@sha256:c19f6bba3ab66fcf30c8737d2361ec9f8e12a4fa8a071481f53366dc13e83340", + upstreamImage: "supabase/postgres-meta:v0.99.0", + natives: { + "darwin-arm64": { + archive: "337f8cc6a23d93f3f9cfeed12de2a86d686ce6f4346ff9b334b5dfdcba7e890f", + manifest: "6b35ee42d334138562444842ed0662b97b3cbde504caa11e97407b137bc8b2ca", + }, + "linux-amd64": { + archive: "43156ba28901710bf02a333dc04c4b30754eb6a2334ff1d890a4a3ea55c02f22", + manifest: "dbd8a7eac705b6c6df16826f22f3317906842be8498ce44fa1229ae6f16273ad", + }, + "linux-arm64": { + archive: "a8565d6e550efa8a8481c7d542b612e7a6d50668321fcd2e668b2ee9bcb28ed7", + manifest: "1b67627c5ccde0f94a4997ab223a0ac072c70e9bf1bb86d3e4d0df42e90b4059", + }, + }, + }, "bin/pgmeta", ), - mail: definition("mailpit", "v1.30.2", "ghcr.io/supabase/cli/mailpit:v1.30.2", "bin/mailpit"), + mail: definition( + "mailpit", + { + upstreamVersion: "v1.31.3", + revision: 0, + image: + "ghcr.io/supabase/cli/mailpit:v1.31.3-r0@sha256:ed9b00c609e77e99c79b93f1178255ebc271868920f2c69a8d166bd5634ed10d", + upstreamImage: "axllent/mailpit:v1.31.3", + natives: { + "darwin-arm64": { + archive: "d460a6a55693a2a321ad83ebb83417b8078324751a744f982bb3512da0632712", + manifest: "43177dc88b6f625bec6d98096e39c10f4cd10956081df01b5b7cebeeae47766a", + }, + "linux-amd64": { + archive: "57e39cb39b2288313e26abe9ae9300f6af9332abd1f76f4d67be533741b2c942", + manifest: "36595519f40e406b17a0ce6e21f153400f217ae24b0365f0a5c495ff315b9f82", + }, + "linux-arm64": { + archive: "805b09d9008e9be2c6c9230e668bd2c6ab446eeae26389e91732f9e20b2c3f5f", + manifest: "973b76700a5a865243bd5a5c36664121ed99c538d58633921cc8fd7c675e655c", + }, + }, + }, + "bin/mailpit", + ), analytics: definition( "analytics", - "v1.50.9", - "ghcr.io/supabase/cli/analytics:v1.50.9@sha256:7db85cc6cb0cdeb4b71f2fadb49c0f9197bea0492daf7896f6ae69edad76d28e", + { + upstreamVersion: "v1.50.15", + revision: 0, + image: + "ghcr.io/supabase/cli/analytics:v1.50.15-r0@sha256:cea1595bafa6ab32df4854d407261ddef6e35394e300ae29770ba48c49c8dc7e", + upstreamImage: "supabase/logflare:1.50.15", + natives: { + "darwin-arm64": { + archive: "336c78c501aff270b0fcd2a42229b76d9f2feafa646f49a59c3de29358e3de17", + manifest: "d0e1a0c3fa0a4ebfcebbe490f290699424e3b8c92a542f50111ff88d6e47b8b5", + }, + "linux-amd64": { + archive: "a699ce1522dac43989987a9d152648ed0506fb887ffedff49c148da02cbbc7e7", + manifest: "ba8c55eeb06be91ad582156854babdc6a06d7a26fa6956bc1271eee4cae9d536", + }, + "linux-arm64": { + archive: "bb977b9cd0623e1b1ece9875377fdf29577f96991d2e5c72a38a1b664c6a7538", + manifest: "1739bbd4afb838e62b6191c4de8aa39685df9f5d83e1b7cc303bd096237ba08c", + }, + }, + }, "bin/logflare", ["bin/logflare", "bin/prepare"], ), - vector: definition("vector", "0.53.0", "ghcr.io/supabase/cli/vector:0.53.0", "bin/vector", [ + vector: definition( + "vector", + { + upstreamVersion: "0.58.0", + revision: 0, + image: + "ghcr.io/supabase/cli/vector:0.58.0-r0@sha256:5dcf67db0ee378caa87f3395cb9484ebe3e97bb0334d119f2ac33116e00c5773", + upstreamImage: "timberio/vector:0.58.0-alpine", + natives: { + "darwin-arm64": { + archive: "567245cf9a7d54eee45ecf74e1c9a61ca6d02cdca103edc7b32b005e54f4e632", + manifest: "a973a763b00599858ceae8f304714fb99f785860112e9e0812ef0df8f81dd2ee", + }, + "linux-amd64": { + archive: "697f4fae35be3026474695bef16336f6fcfd429ce8cfba884c595896e96c30ec", + manifest: "8989b8b061f08bd7653e9ae71c6ee0e5cf01a0b10f2d358fe3dbc671ec5b63e1", + }, + "linux-arm64": { + archive: "c66b8ad0a0dd0fdcb3e4ee36bae8040b7b23b44ec2b4023565ca335884268c1d", + manifest: "992467ec68a99a6413468b9311294abb7a1f86b7857ad37f1f3bd9e6aecc9dbb", + }, + }, + }, "bin/vector", - "share/doc/vector/config/vector.yaml", - ]), + ["bin/vector", "share/doc/vector/config/vector.yaml"], + ), pooler: definition( "pooler", - "v2.9.12", - "ghcr.io/supabase/cli/pooler:v2.9.12@sha256:12bb9dcb7ddace79bee173ccb7327c6646af2236679f3bd932a86b3a06479aac", + { + upstreamVersion: "v2.9.13", + revision: 0, + image: + "ghcr.io/supabase/cli/pooler:v2.9.13-r0@sha256:3a32b56d03675ed24e84408afcbb12fa52b7ec6e7741f913770fea5b66c2ddd3", + upstreamImage: "supabase/supavisor:2.9.13", + natives: { + "darwin-arm64": { + archive: "c05285be2a945a29d5be491661926f8c88d976540d49ddbcab10aa7276b29934", + manifest: "0fafa8dcf601ff3cea7326e82f7a5191ffe03e3e4edd0d2b155e80e8c9f53d78", + }, + "linux-amd64": { + archive: "d94e36f44267b4159fa55e1aea39320a68789b6af07f9cc87c27327ed6a00602", + manifest: "4f181d1d25da91f72ca37c22cdc46278a424a4edef6bdd2ea2c30f463c629a5a", + }, + "linux-arm64": { + archive: "124f3e6c95e6ba985239e013cab76c5be28fbd92cb4986b687dd5e827f0525ca", + manifest: "c6cdc298bc3a00d082a1c9c34f4131f13437df51fb0bf9a0ce857b0a2fd66460", + }, + }, + }, "bin/server", ["bin/server", "bin/prepare", "bin/provision-tenant"], ), @@ -174,10 +492,6 @@ const SLIM_NATIVE_GITHUB_RELEASES = "https://github.com/supabase/slim-services/r */ const SLIM_NATIVE_SUPABASE_S3_MIRROR = "https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com"; -const SLIM_NATIVE_GHCR_REGISTRY = "ghcr.io"; -const SLIM_NATIVE_GHCR_REPOSITORY = "supabase/cli"; - -const SLIM_IMAGE_GHCR_REGISTRY = "ghcr.io/supabase/cli/"; const SLIM_IMAGE_SUPABASE_ECR_MIRROR = "public.ecr.aws/supabase/cli/"; /** Mirrors carrying a catalog slim image under the same tag and digest, in fallback order. */ @@ -192,27 +506,21 @@ const artifactFor = ( target: NativeTarget, ): SlimServicesArtifact => { const sourceService = definitions[service].sourceService; - const releaseTag = `${sourceService}-${resolved.version}`; + const releaseTag = `${sourceService}-${resolved.releaseVersion}`; const assetName = `${releaseTag}-${target}`; const githubRelease = `${SLIM_NATIVE_GITHUB_RELEASES}/${releaseTag}`; - const supabaseS3 = `${SLIM_NATIVE_SUPABASE_S3_MIRROR}/${sourceService}/${resolved.version}`; + const supabaseS3 = `${SLIM_NATIVE_SUPABASE_S3_MIRROR}/${sourceService}/${resolved.releaseVersion}`; + const pin = resolved.natives[target]; return { provider: "supabase/slim-services", service: sourceService, - version: resolved.version, + version: resolved.releaseVersion, releaseTag, target, archive: "tar.zst", assetName, - checksums: [ - { kind: "sha256sums", url: `${githubRelease}/SHA256SUMS` }, - { - kind: "oci", - registry: SLIM_NATIVE_GHCR_REGISTRY, - repository: `${SLIM_NATIVE_GHCR_REPOSITORY}/${sourceService}`, - tag: `${resolved.version}-native-${target}`, - }, - ], + sha256: pin.archive, + manifestSha256: pin.manifest, mirrors: [ { downloadUrl: `${githubRelease}/${assetName}.tar.zst`, @@ -236,8 +544,8 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* return yield* new ArtifactError({ message: `Unknown service kind: ${request.service}` }); const selected = definitions[request.service]; const version = request.version ?? selected.defaultVersion; - const image = Object.entries(selected.images).find(([candidate]) => candidate === version)?.[1]; - if (image === undefined) + const pin = Object.entries(selected.pins).find(([candidate]) => candidate === version)?.[1]; + if (pin === undefined) return yield* new ArtifactError({ message: `Unsupported ${request.service} artifact version: ${version}`, service: request.service, @@ -246,7 +554,9 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* return { service: request.service, version, - image, + releaseVersion: releaseVersion(pin), + image: pin.image, + natives: pin.natives, executablePath: selected.executablePath, requiredRuntimePaths: selected.requiredRuntimePaths, }; @@ -254,13 +564,33 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* /** Resolves a PostgreSQL major alias against the pinned database artifacts. */ export const postgresVersion = (version: string): string => - Object.keys(definitions.database.images).find( - (candidate) => candidate.split(".")[0] === version, - ) ?? version; + Object.keys(definitions.database.pins).find((candidate) => candidate.split(".")[0] === version) ?? + version; /** Service kinds in artifact catalog order. */ export const artifactServiceKinds = (): ReadonlyArray<ServiceKind> => Record.keys(definitions); +/** + * Every catalog pin in catalog order, including additional upstream lines. `isDefault` marks the + * pin `resolveArtifact` picks when no version is requested (postgres's 17.x line today); every + * other pin (postgres's 15.x additional line) carries `isDefault: false`. + */ +export const catalogPins = (): ReadonlyArray<{ + readonly service: ServiceKind; + readonly sourceService: string; + readonly pin: ArtifactPin; + readonly isDefault: boolean; +}> => + artifactServiceKinds().flatMap((service) => { + const { sourceService, defaultVersion, pins } = definitions[service]; + return Object.values(pins).map((pin) => ({ + service, + sourceService, + pin, + isDefault: pin.upstreamVersion === defaultVersion, + })); + }); + const artifactKey = (artifact: SlimServicesArtifact): string => `slim-services/${artifact.service}/${artifact.version}/${artifact.target}`; diff --git a/packages/stack/src/Artifacts.unit.test.ts b/packages/stack/src/Artifacts.unit.test.ts index 0e0ecf510d..50c55fd78c 100644 --- a/packages/stack/src/Artifacts.unit.test.ts +++ b/packages/stack/src/Artifacts.unit.test.ts @@ -1,5 +1,14 @@ import { expect, it } from "@effect/vitest"; -import { slimImageMirrors } from "./Artifacts.ts"; +import { catalogPins, slimImageMirrors } from "./Artifacts.ts"; + +it("carries a normalized upstreamImage for every catalog pin", () => { + for (const { pin } of catalogPins()) { + expect(pin.upstreamImage).not.toBe(""); + expect(pin.upstreamImage).not.toContain("docker.io/"); + expect(pin.upstreamImage).not.toContain("@sha256:"); + expect(pin.upstreamImage.split(":").at(-1)).not.toBe(""); + } +}); it("rewrites a GHCR catalog image onto ECR Public and keeps the tag and digest", () => { expect( diff --git a/packages/stack/src/host/CommandRunner.initialization.integration.test.ts b/packages/stack/src/host/CommandRunner.initialization.integration.test.ts index 28e1c4c48d..269d9307c2 100644 --- a/packages/stack/src/host/CommandRunner.initialization.integration.test.ts +++ b/packages/stack/src/host/CommandRunner.initialization.integration.test.ts @@ -2,6 +2,7 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Context, Deferred, Effect, Fiber, FileSystem, Layer, Path, Ref } from "effect"; import { TestClock } from "effect/testing"; +import { catalogPins, resolveArtifact } from "../Artifacts.ts"; import { initialization } from "../Commands.ts"; import { makeArtifactStore, @@ -21,11 +22,20 @@ const target = ? "linux-arm64" : undefined; +// The real catalog's default auth pin, not hardcoded — `resolveArtifact({ service: "auth", +// version })` below would otherwise fail once a catalog bump moves past a literal. +const authVersion = catalogPins().find((entry) => entry.sourceService === "auth" && entry.isDefault) + ?.pin.upstreamVersion; +if (authVersion === undefined) { + throw new Error("no default auth catalog pin found"); +} + const prepareAuthArtifact = Effect.fn(function* (cacheRoot: string, script: string) { if (target === undefined) return yield* Effect.fail("Unsupported test platform"); const fs = yield* FileSystem.FileSystem; + const { releaseVersion } = yield* resolveArtifact({ service: "auth" }); const request: ArtifactRequest = { - key: `slim-services/auth/v2.196.0/${target}`, + key: `slim-services/auth/${releaseVersion}/${target}`, requiredRuntimePaths: ["bin/auth"], executablePath: "bin/auth", }; @@ -94,7 +104,7 @@ it.live.skipIf(target === undefined || process.platform === "win32")( const error = yield* runner .run({ command: initialization.auth({ - version: "v2.196.0", + version: authVersion, databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", }), credentials, @@ -137,7 +147,7 @@ it.live.skipIf(target === undefined || process.platform === "win32")( const command = yield* runner .run({ command: initialization.auth({ - version: "v2.196.0", + version: authVersion, databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", }), credentials, @@ -195,7 +205,7 @@ it.effect.skipIf(target === undefined || process.platform === "win32")( const command = yield* runner .run({ command: initialization.auth({ - version: "v2.196.0", + version: authVersion, databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", }), credentials, diff --git a/packages/stack/src/internal/artifacts.ts b/packages/stack/src/internal/artifacts.ts index cce55dec8c..0ee05f1fac 100644 --- a/packages/stack/src/internal/artifacts.ts +++ b/packages/stack/src/internal/artifacts.ts @@ -2,8 +2,12 @@ export { ArtifactError, artifactServiceKinds, + catalogPins, defaultRuntime, postgresVersion, prepareNativeArtifact, resolveArtifact, + type ArtifactPin, + type NativePin, + type ServiceKind, } from "../Artifacts.ts"; diff --git a/packages/stack/src/preparation/Integrity.ts b/packages/stack/src/preparation/Integrity.ts index 381aef46ca..a376dc6618 100644 --- a/packages/stack/src/preparation/Integrity.ts +++ b/packages/stack/src/preparation/Integrity.ts @@ -35,7 +35,7 @@ export const verifySha256 = Effect.fn("Integrity.verifySha256")(function* ( ); const actual = digestHex(digest); if (actual !== canonical) - return yield* integrityError("Artifact SHA-256 does not match the catalog", { + return yield* integrityError(`expected ${canonical}, got ${actual}`, { expected: canonical, actual, }); diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index f4453347b6..97ed1487e5 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -7,6 +7,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { errorChainMessage } from "../internal/error-message.ts"; import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; import { PreparationError } from "./Errors.ts"; +import { verifySha256 } from "./Integrity.ts"; /** One host serving the archive and manifest of a slim-services release asset. */ interface SlimServicesMirror { @@ -14,33 +15,20 @@ interface SlimServicesMirror { readonly manifestUrl: string; } -/** - * Where the expected archive digest comes from: a release `SHA256SUMS` file, or the archive layer - * of the native OCI artifact in a registry that allows anonymous pulls. - */ -type SlimServicesChecksumSource = - | { readonly kind: "sha256sums"; readonly url: string } - | { - readonly kind: "oci"; - readonly registry: string; - readonly repository: string; - readonly tag: string; - }; - export interface SlimServicesArtifact { readonly provider: "supabase/slim-services"; readonly service: string; + /** Release version, `<upstream>-r<revision>`. */ readonly version: string; readonly releaseTag: string; readonly target: "darwin-arm64" | "linux-amd64" | "linux-arm64"; readonly archive: "tar.zst"; readonly assetName: string; - /** Checksum authorities, tried in order; a mirror's own checksum file counts only when listed here. */ - readonly checksums: readonly [ - SlimServicesChecksumSource, - ...ReadonlyArray<SlimServicesChecksumSource>, - ]; - /** Hosts carrying the same release assets, tried in order until one serves them. */ + /** Pinned archive digest. Mirrors only serve bytes; they never supply a digest. */ + readonly sha256: string; + /** Pinned manifest digest, verified before the manifest is parsed. */ + readonly manifestSha256: string; + /** Hosts carrying the same release assets, tried in order until one serves the pinned bytes. */ readonly mirrors: readonly [SlimServicesMirror, ...ReadonlyArray<SlimServicesMirror>]; readonly requiredRuntimePaths: ReadonlyArray<string>; readonly executablePath: string; @@ -97,9 +85,9 @@ const systemTarBoundary: TarBoundary = { ); }), }; -const responseFor = (url: string, headers?: Readonly<Record<string, string>>) => +const responseFor = (url: string) => Effect.flatMap(HttpClient.HttpClient, (client) => - HttpClient.followRedirects(client).get(url, { headers }), + HttpClient.followRedirects(client).get(url), ).pipe( Effect.flatMap((response) => Effect.gen(function* () { @@ -145,9 +133,8 @@ const withTransferRetry = const fetchBytes = Effect.fn("SlimServicesSource.fetchBytes")(function* ( url: string, backoff: Schedule.Schedule<unknown>, - headers?: Readonly<Record<string, string>>, ) { - return yield* responseFor(url, headers).pipe( + return yield* responseFor(url).pipe( Effect.flatMap((response) => response.arrayBuffer), Effect.map((bytes) => new Uint8Array(bytes)), withTransferRetry(url, backoff), @@ -274,93 +261,6 @@ const firstSuccess = <T, A, R>( ); }; -const checksumFor = (contents: string, archiveName: string): string | undefined => - contents - .split(/\r?\n/u) - .map((line) => line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/iu)) - .find((match) => match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`))?.[1]; - -const ARCHIVE_MEDIA_TYPE = "application/vnd.supabase.slim.archive.v1.tar+zstd"; - -const RegistryToken = Schema.Struct({ token: Schema.String }); - -const NativeOciManifest = Schema.Struct({ - layers: Schema.Array(Schema.Struct({ mediaType: Schema.String, digest: Schema.String })), -}); - -const decodeJson = <S extends Schema.Top>(schema: S, bytes: Uint8Array, message: string) => - Schema.decodeEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(bytes)).pipe( - Effect.mapError((cause) => new PreparationError({ message, cause })), - ); - -/** Reads the archive layer digest of a native OCI artifact through an anonymous pull token. */ -const ociArchiveDigest = Effect.fn("SlimServicesSource.ociArchiveDigest")(function* ( - source: Extract<SlimServicesChecksumSource, { readonly kind: "oci" }>, - backoff: Schedule.Schedule<unknown>, -) { - const tokenUrl = `https://${source.registry}/token?scope=repository:${source.repository}:pull&service=${source.registry}`; - const { token } = yield* decodeJson( - RegistryToken, - yield* fetchBytes(tokenUrl, backoff), - "Slim-services registry token is invalid", - ); - const manifest = yield* decodeJson( - NativeOciManifest, - yield* fetchBytes( - `https://${source.registry}/v2/${source.repository}/manifests/${source.tag}`, - backoff, - { - accept: "application/vnd.oci.image.manifest.v1+json", - authorization: `Bearer ${token}`, - }, - ), - "Slim-services native OCI manifest is invalid", - ); - const digest = manifest.layers - .find((layer) => layer.mediaType === ARCHIVE_MEDIA_TYPE) - ?.digest.match(/^sha256:([a-f0-9]{64})$/u)?.[1]; - if (digest === undefined) - return yield* new PreparationError({ - message: "Slim-services native OCI manifest has no archive layer", - }); - return digest; -}); - -const describeChecksumSource = (source: SlimServicesChecksumSource): string => - source.kind === "sha256sums" - ? source.url - : `${source.registry}/${source.repository}:${source.tag}`; - -export const slimServicesChecksum = Effect.fn("SlimServicesSource.checksum")(function* ( - artifact: SlimServicesArtifact, - backoff: Schedule.Schedule<unknown> = transferBackoff, -) { - return yield* firstSuccess( - "Unable to resolve the slim-services checksum", - artifact, - artifact.checksums, - describeChecksumSource, - (source) => - source.kind === "oci" - ? ociArchiveDigest(source, backoff) - : fetchBytes(source.url, backoff).pipe( - Effect.map((bytes) => new TextDecoder().decode(bytes)), - Effect.flatMap((contents) => { - const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); - return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) - ? Effect.fail( - new PreparationError({ - message: "Slim-services checksum is missing", - service: artifact.service, - version: artifact.version, - }), - ) - : Effect.succeed(checksum.toLowerCase()); - }), - ), - ); -}); - const manifestSchema = Schema.Struct({ service: Schema.String, version: Schema.String, @@ -375,6 +275,18 @@ const verifiedManifest = Effect.fn("SlimServicesSource.verifiedManifest")(functi backoff: Schedule.Schedule<unknown>, ) { const manifestBytes = yield* fetchBytes(mirror.manifestUrl, backoff); + yield* verifySha256(manifestBytes, artifact.manifestSha256).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: "Slim-services manifest does not match its pin", + service: artifact.service, + version: artifact.version, + target: artifact.target, + cause, + }), + ), + ); const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(manifestSchema))( new TextDecoder().decode(manifestBytes), ).pipe( @@ -497,10 +409,7 @@ export const makeSlimServicesSource = ( return artifact; }); return { - checksum: (request) => - resolveArtifact(request).pipe( - Effect.flatMap((artifact) => slimServicesChecksum(artifact, backoff)), - ), + checksum: (request) => resolveArtifact(request).pipe(Effect.map(({ sha256 }) => sha256)), materialize: Effect.fn("SlimServicesSource.materialize")( function* (request, destination, expectedSha256, onProgress) { const fs = yield* FileSystem.FileSystem; diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 9423210184..c321aae3c1 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -17,12 +17,11 @@ import { import { createServer, type Server } from "node:http"; import { zstdCompress } from "node:zlib"; import { FetchHttpClient } from "effect/unstable/http"; -import { prepareNativeArtifact, resolveArtifact } from "../Artifacts.ts"; -import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; +import { catalogPins, prepareNativeArtifact, resolveArtifact } from "../Artifacts.ts"; +import { makeArtifactStore, type ArtifactRequest, type ArtifactSource } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; import { makeSlimServicesSource, - slimServicesChecksum, type SlimServicesArtifact, type ZstdDecompressor, } from "./SlimServicesSource.ts"; @@ -34,15 +33,31 @@ const waitForAbort = (signal?: AbortSignal | null): Promise<never> => const waitForRelease = (released: Deferred.Deferred<void>): Promise<void> => Effect.runPromise(Deferred.await(released)); +const manifestBytes = (version: string, service = "demo"): Uint8Array => + new TextEncoder().encode(JSON.stringify({ service, version, target: "linux-amd64" })); + +const demoManifest = manifestBytes("v1.0.0-r0"); + +/** Passes every manifest check except its pin. */ +const commandManifest = new TextEncoder().encode( + JSON.stringify({ + service: "demo", + version: "v1.0.0-r0", + target: "linux-amd64", + cmd: ["bin/evil"], + }), +); + const artifact: SlimServicesArtifact = { provider: "supabase/slim-services", service: "demo", - version: "v1.0.0", - releaseTag: "demo-v1.0.0", + version: "v1.0.0-r0", + releaseTag: "demo-v1.0.0-r0", target: "linux-amd64", archive: "tar.zst", - assetName: "demo-v1.0.0-linux-amd64", - checksums: [{ kind: "sha256sums", url: "https://example.test/SHA256SUMS" }], + assetName: "demo-v1.0.0-r0-linux-amd64", + sha256: "0".repeat(64), + manifestSha256: "0".repeat(64), mirrors: [ { downloadUrl: "https://example.test/demo.tar.zst", @@ -52,6 +67,34 @@ const artifact: SlimServicesArtifact = { requiredRuntimePaths: ["bin/demo"], executablePath: "bin/demo", }; + +const releaseMirror = { + downloadUrl: "https://release.test/demo-v1.0.0-r0-linux-amd64.tar.zst", + manifestUrl: "https://release.test/demo-v1.0.0-r0-linux-amd64.manifest.json", +}; +const bucketMirror = { + downloadUrl: "https://bucket.test/demo-v1.0.0-r0-linux-amd64.tar.zst", + manifestUrl: "https://bucket.test/demo-v1.0.0-r0-linux-amd64.manifest.json", +}; + +const sha256Of = Effect.fn(function* (bytes: Uint8Array) { + const crypto = yield* Crypto.Crypto; + return digestHex(yield* crypto.digest("SHA-256", bytes)); +}); + +/** Pins `base` to the digests of the given archive and manifest bytes. */ +const pinned = Effect.fn(function* ( + archive: Uint8Array, + manifest: Uint8Array = demoManifest, + base: SlimServicesArtifact = artifact, +) { + return { + ...base, + sha256: yield* sha256Of(archive), + manifestSha256: yield* sha256Of(manifest), + } satisfies SlimServicesArtifact; +}); + const request: ArtifactRequest = { key: "demo/v1", requiredRuntimePaths: ["bin/demo"], @@ -138,59 +181,62 @@ const withFetch = <A, E, R>(fetcher: FetchLike, effect: Effect.Effect<A, E, R>) Effect.provideService(FetchHttpClient.Fetch, fetcher), ); -const registryChecksum = { - kind: "oci", - registry: "registry.test", - repository: "supabase/cli/demo", - tag: "v1.0.0-native-linux-amd64", -} as const; +/** Serves each host's manifest and archive, answers 403 elsewhere, and records every URL. */ +const serving = + ( + hosts: Readonly< + Record<string, { readonly archive: Uint8Array; readonly manifest: Uint8Array }> + >, + requested: Array<string> = [], + ): FetchLike => + (input) => { + const url = requestUrl(input); + requested.push(url); + const host = hosts[new URL(url).host]; + if (host === undefined) return Promise.resolve(new Response("", { status: 403 })); + return Promise.resolve( + new Response(url.endsWith(".manifest.json") ? host.manifest : host.archive), + ); + }; -/** Anonymous registry token and native manifest; the manifest requires the issued token. */ -const registryResponse = ( - input: Parameters<typeof fetch>[0], - init: Parameters<typeof fetch>[1], - archiveSha256: string, - registry = "registry.test", - repository = "supabase/cli/demo", - tag = "v1.0.0-native-linux-amd64", -): Response | undefined => { - const url = requestUrl(input); - if (url === `https://${registry}/token?scope=repository:${repository}:pull&service=${registry}`) - return new Response(JSON.stringify({ token: "anonymous" })); - if (url !== `https://${registry}/v2/${repository}/manifests/${tag}`) return undefined; - if (new Headers(init?.headers).get("authorization") !== "Bearer anonymous") - return new Response("", { status: 401 }); - return new Response( - JSON.stringify({ - layers: [ - { - mediaType: "application/vnd.supabase.slim.archive.v1.tar+zstd", - digest: `sha256:${archiveSha256}`, - }, - { - mediaType: "application/vnd.supabase.slim.checksum.v1", - digest: `sha256:${"f".repeat(64)}`, - }, - ], - }), - ); -}; +const fixtureSource = (content: string): ArtifactSource => ({ + checksum: () => Effect.succeed("0".repeat(64)), + materialize: (entry, destination) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const file of entry.requiredRuntimePaths) { + yield* fs.makeDirectory(`${destination}/${file.slice(0, file.lastIndexOf("/"))}`, { + recursive: true, + }); + yield* fs.writeFileString(`${destination}/${file}`, content); + yield* fs.chmod(`${destination}/${file}`, 0o755); + } + }).pipe( + Effect.mapError( + (cause) => new PreparationError({ message: "Unable to write cache fixture", cause }), + ), + ), +}); describe("slim-services artifact source", () => { it.live("follows release redirects through the supplied Node HTTP client", () => Effect.scoped( Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); const server = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => // oxlint-disable-next-line effecttsgo/new-promise -- node server listen exposes a callback lifecycle. new Promise<Server>((resolve, reject) => { const value = createServer((request, response) => { - if (request.url === "/redirect") { + const url = request.url ?? ""; + if (url.startsWith("/redirect/")) { response.statusCode = 302; - response.setHeader("location", "/checksums"); + response.setHeader("location", url.slice("/redirect".length)); + response.end(); + return; } - response.end("a".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"); + response.end(url.endsWith(".manifest.json") ? demoManifest : archive); }); value.once("error", reject); value.listen(0, "127.0.0.1", () => resolve(value)); @@ -207,50 +253,62 @@ describe("slim-services artifact source", () => { const address = server.address(); if (address === null || typeof address === "string") return yield* Effect.die("redirect server did not expose a port"); - const redirected: SlimServicesArtifact = { + const origin = `http://127.0.0.1:${address.port}/redirect`; + const redirected = yield* pinned(archive, demoManifest, { ...artifact, - checksums: [{ kind: "sha256sums", url: `http://127.0.0.1:${address.port}/redirect` }], - }; - const checksum = yield* slimServicesChecksum(redirected); - expect(checksum).toBe("a".repeat(64)); + mirrors: [ + { + downloadUrl: `${origin}/demo.tar.zst`, + manifestUrl: `${origin}/demo.manifest.json`, + }, + ], + }); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-redirect-", + }); + yield* makeSlimServicesSource(() => redirected).materialize( + request, + destination, + redirected.sha256, + ); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ), ); - it.live("verifies checksums and extracts a manifest-matched archive using injected fetch", () => + it.live("prepares a pinned archive and manifest without fetching any checksum", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const checksums = expected + " demo-v1.0.0-linux-amd64.tar.zst\n"; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) return Promise.resolve(new Response(checksums)); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - expect(yield* withFetch(fetcher, slimServicesChecksum(artifact))).toBe(expected); - const source = makeSlimServicesSource(() => artifact); + const demo = yield* pinned(archive); + const requested: string[] = []; + const fetcher = serving({ "example.test": { archive, manifest: demoManifest } }, requested); const fs = yield* FileSystem.FileSystem; - const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-source-" }); - yield* withFetch(fetcher, source.materialize(request, destination, expected)); - expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-" }); + const store = yield* makeArtifactStore({ + cacheRoot: root, + source: makeSlimServicesSource(() => demo), + }); + + const prepared = yield* withFetch(fetcher, store.prepare(request)); + expect(prepared.outcome).toBe("downloaded"); + expect(prepared.sha256).toBe(demo.sha256); + expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); + expect(requested).toEqual([demo.mirrors[0].manifestUrl, demo.mirrors[0].downloadUrl]); + + const cached = yield* withFetch(fetcher, store.prepare(request)); + expect(cached.outcome).toBe("cached"); + expect(requested).toHaveLength(2); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("retries a gateway error on the checksum and a truncated archive transfer", () => + it.live("retries a gateway error on the manifest and a truncated archive transfer", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const demo = yield* pinned(archive); const attempts = new Map<string, number>(); const count = (url: string): number => { const next = (attempts.get(url) ?? 0) + 1; @@ -266,17 +324,9 @@ describe("slim-services artifact source", () => { }); const flaky: FetchLike = (input) => { const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - count(url) === 1 - ? new Response("", { status: 504 }) - : new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`), - ); if (url.endsWith("manifest.json")) return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), + count(url) === 1 ? new Response("", { status: 504 }) : new Response(demoManifest), ); return Promise.resolve( count(url) === 1 ? new Response(truncated()) : new Response(archive), @@ -284,78 +334,68 @@ describe("slim-services artifact source", () => { }; const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-retry-" }); - const source = makeSlimServicesSource(() => artifact, { backoff: immediate }); - expect(yield* withFetch(flaky, source.checksum(request))).toBe(expected); - expect(attempts.get("https://example.test/SHA256SUMS")).toBe(2); - yield* withFetch(flaky, source.materialize(request, destination, expected)); + const source = makeSlimServicesSource(() => demo, { backoff: immediate }); + yield* withFetch(flaky, source.materialize(request, destination, demo.sha256)); expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); - expect(attempts.get(artifact.mirrors[0].downloadUrl)).toBe(2); + expect(attempts.get(demo.mirrors[0].manifestUrl)).toBe(2); + expect(attempts.get(demo.mirrors[0].downloadUrl)).toBe(2); }).pipe(Effect.provide(NodeServices.layer)), ), ); it.live("spends five attempts on a persistent gateway error and one on a missing asset", () => - Effect.gen(function* () { - let gateway = 0; - const exhausted = yield* withFetch(() => { - gateway += 1; - return Promise.resolve(new Response("", { status: 504 })); - }, slimServicesChecksum(artifact, immediate).pipe(Effect.exit)); - expect(errorOf(exhausted)).toBeInstanceOf(PreparationError); - expect(gateway).toBe(5); - - let missing = 0; - const failed = yield* withFetch(() => { - missing += 1; - return Promise.resolve(new Response("", { status: 404 })); - }, slimServicesChecksum(artifact, immediate).pipe(Effect.exit)); - expect(errorOf(failed)).toBeInstanceOf(PreparationError); - expect(missing).toBe(1); - }), + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source = makeSlimServicesSource(() => artifact, { backoff: immediate }); + + let gateway = 0; + const exhausted = yield* withFetch( + () => { + gateway += 1; + return Promise.resolve(new Response("", { status: 504 })); + }, + source + .materialize( + request, + yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-gateway-" }), + artifact.sha256, + ) + .pipe(Effect.exit), + ); + expect(errorOf(exhausted)).toBeInstanceOf(PreparationError); + expect(gateway).toBe(5); + + let missing = 0; + const failed = yield* withFetch( + () => { + missing += 1; + return Promise.resolve(new Response("", { status: 404 })); + }, + source + .materialize( + request, + yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-missing-" }), + artifact.sha256, + ) + .pipe(Effect.exit), + ); + expect(errorOf(failed)).toBeInstanceOf(PreparationError); + expect(missing).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), + ), ); it.live("prepares the artifact from the next mirror when the release host is blocked", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const mirrored: SlimServicesArtifact = { + const mirrored = yield* pinned(archive, demoManifest, { ...artifact, - checksums: [ - { kind: "sha256sums", url: "https://release.test/SHA256SUMS" }, - registryChecksum, - ], - mirrors: [ - { - downloadUrl: "https://release.test/demo-v1.0.0-linux-amd64.tar.zst", - manifestUrl: "https://release.test/demo-v1.0.0-linux-amd64.manifest.json", - }, - { - downloadUrl: "https://bucket.test/demo-v1.0.0-linux-amd64.tar.zst", - manifestUrl: "https://bucket.test/demo-v1.0.0-linux-amd64.manifest.json", - }, - ], - }; - const blocked = new Set<string>(); - const bucket: string[] = []; - const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.startsWith("https://release.test/")) { - blocked.add(url); - return Promise.resolve(new Response("", { status: 403 })); - } - const registry = registryResponse(input, init, expected); - if (registry !== undefined) return Promise.resolve(registry); - bucket.push(url); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + mirrors: [releaseMirror, bucketMirror], + }); + const requested: string[] = []; + const fetcher = serving({ "bucket.test": { archive, manifest: demoManifest } }, requested); const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-fallback-" }); const store = yield* makeArtifactStore({ @@ -365,100 +405,140 @@ describe("slim-services artifact source", () => { const prepared = yield* withFetch(fetcher, store.prepare(request)); expect(prepared.outcome).toBe("downloaded"); expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); - expect([...blocked]).toEqual([ - "https://release.test/SHA256SUMS", - "https://release.test/demo-v1.0.0-linux-amd64.manifest.json", + expect(requested).toEqual([ + releaseMirror.manifestUrl, + bucketMirror.manifestUrl, + bucketMirror.downloadUrl, ]); - expect(bucket).toEqual([ - "https://bucket.test/demo-v1.0.0-linux-amd64.manifest.json", - "https://bucket.test/demo-v1.0.0-linux-amd64.tar.zst", + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("falls through to the next mirror when the primary serves a tampered archive", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const tampered = yield* compress(tar("bin/demo", "evil")); + const mirrored = yield* pinned(archive, demoManifest, { + ...artifact, + mirrors: [releaseMirror, bucketMirror], + }); + const requested: string[] = []; + const fetcher = serving( + { + "release.test": { archive: tampered, manifest: demoManifest }, + "bucket.test": { archive, manifest: demoManifest }, + }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-archive-" }); + yield* withFetch( + fetcher, + makeSlimServicesSource(() => mirrored).materialize(request, destination, mirrored.sha256), + ); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + expect(requested).toEqual([ + releaseMirror.manifestUrl, + releaseMirror.downloadUrl, + bucketMirror.manifestUrl, + bucketMirror.downloadUrl, ]); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("names every failed checksum source in the aggregated error", () => - Effect.gen(function* () { - const mirrored: SlimServicesArtifact = { - ...artifact, - checksums: [ - { kind: "sha256sums", url: "https://release.test/SHA256SUMS" }, - registryChecksum, - ], - }; - const requested: string[] = []; - const failed = yield* withFetch((input) => { - requested.push(requestUrl(input)); - return Promise.resolve(new Response("", { status: 403 })); - }, slimServicesChecksum(mirrored, immediate).pipe(Effect.exit)); - expect(errorOf(failed)?.message).toBe( - "Unable to resolve the slim-services checksum: https://release.test/SHA256SUMS " + - "(Unable to download https://release.test/SHA256SUMS: HTTP 403); " + - "registry.test/supabase/cli/demo:v1.0.0-native-linux-amd64 (Unable to download " + - "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test: HTTP 403)", - ); - expect(errorOf(failed)).toMatchObject({ - service: artifact.service, - version: artifact.version, - }); - expect(requested).toEqual([ - "https://release.test/SHA256SUMS", - "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test", - ]); - }), + it.live("falls through to the next mirror when the primary serves a tampered manifest", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const mirrored = yield* pinned(archive, demoManifest, { + ...artifact, + mirrors: [releaseMirror, bucketMirror], + }); + const requested: string[] = []; + const fetcher = serving( + { + "release.test": { archive, manifest: commandManifest }, + "bucket.test": { archive, manifest: demoManifest }, + }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-manifest-", + }); + yield* withFetch( + fetcher, + makeSlimServicesSource(() => mirrored).materialize(request, destination, mirrored.sha256), + ); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + expect(requested).toEqual([ + releaseMirror.manifestUrl, + bucketMirror.manifestUrl, + bucketMirror.downloadUrl, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ), ); - it.live("accepts a fallback archive only when it matches the checksum", () => + it.live("names every mirror and its mismatch when none serves the pinned bytes", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); const tampered = yield* compress(tar("bin/demo", "evil")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const mirrored = (fallback: Uint8Array): SlimServicesArtifact => ({ + const tamperedManifest = manifestBytes("v1.0.0-r0", "evil"); + const mirrored = yield* pinned(archive, demoManifest, { ...artifact, - mirrors: [ - { - downloadUrl: "https://release.test/demo.tar.zst", - manifestUrl: "https://release.test/demo.manifest.json", - }, - { - downloadUrl: `https://bucket.test/${fallback === archive ? "good" : "bad"}.tar.zst`, - manifestUrl: "https://bucket.test/demo.manifest.json", - }, - ], + mirrors: [releaseMirror, bucketMirror], + }); + const fetcher = serving({ + "release.test": { archive: tampered, manifest: demoManifest }, + "bucket.test": { archive, manifest: tamperedManifest }, }); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(url.endsWith("good.tar.zst") ? archive : tampered)); - }; const fs = yield* FileSystem.FileSystem; - - const recovered = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-recover-" }); - yield* withFetch( + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-reject-" }); + const failed = yield* withFetch( fetcher, - makeSlimServicesSource(() => mirrored(archive)).materialize(request, recovered, expected), + makeSlimServicesSource(() => mirrored) + .materialize(request, destination, mirrored.sha256) + .pipe(Effect.exit), ); - expect(yield* fs.readFileString(`${recovered}/bin/demo`)).toBe("demo"); + expect(errorOf(failed)?.message).toBe( + "Unable to download the slim-services archive: " + + `${releaseMirror.downloadUrl} (Unable to download slim-services archive: ` + + `expected ${mirrored.sha256}, got ${yield* sha256Of(tampered)}); ` + + `${bucketMirror.downloadUrl} (Slim-services manifest does not match its pin: ` + + `expected ${mirrored.manifestSha256}, got ${yield* sha256Of(tamperedManifest)})`, + ); + expect(yield* fs.exists(`${destination}/bin/demo`)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); - const rejected = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-reject-" }); + it.live("rejects a manifest that names the upstream version instead of the release", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const upstreamManifest = manifestBytes("v1.0.0"); + const demo = yield* pinned(archive, upstreamManifest); + const requested: string[] = []; + const fetcher = serving( + { "example.test": { archive, manifest: upstreamManifest } }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-version-" }); const failed = yield* withFetch( fetcher, - makeSlimServicesSource(() => mirrored(tampered)) - .materialize(request, rejected, expected) + makeSlimServicesSource(() => demo) + .materialize(request, destination, demo.sha256) .pipe(Effect.exit), ); - const message = errorOf(failed)?.message; - expect(message).toContain("Unable to download the slim-services archive"); - expect(message).toContain("https://release.test/demo.tar.zst"); - expect(message).toContain("https://bucket.test/bad.tar.zst"); - expect(yield* fs.exists(`${rejected}/bin/demo`)).toBe(false); + expect(errorOf(failed)?.message).toBe( + "Slim-services manifest does not match the catalog artifact", + ); + expect(requested).toEqual([demo.mirrors[0].manifestUrl]); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -467,26 +547,14 @@ describe("slim-services artifact source", () => { Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("../outside", "unsafe")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - const source = makeSlimServicesSource(() => artifact); + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-unsafe-" }); const failed = yield* withFetch( - fetcher, - source.materialize(request, destination, expected).pipe(Effect.exit), + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo) + .materialize(request, destination, demo.sha256) + .pipe(Effect.exit), ); expect(errorOf(failed)).toBeInstanceOf(PreparationError); expect(yield* fs.exists(`${destination}/outside`)).toBe(false); @@ -503,48 +571,21 @@ describe("slim-services artifact source", () => { { name: "bin/current", link: "demo" }, ]), ); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-links-" }); yield* withFetch( - fetcher, - makeSlimServicesSource(() => artifact).materialize(request, destination, expected), + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ); expect(yield* fs.readFileString(`${destination}/bin/current`)).toBe("demo"); const malformed = yield* compress(new Uint8Array([1, 2, 3])); - const malformedDigest = digestHex(yield* crypto.digest("SHA-256", malformed)); - const malformedFetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response(malformedDigest + " demo-v1.0.0-linux-amd64.tar.zst\n"), - ); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(malformed)); - }; + const malformedDemo = yield* pinned(malformed); const failed = yield* withFetch( - malformedFetcher, - makeSlimServicesSource(() => artifact) - .materialize(request, destination, malformedDigest) + serving({ "example.test": { archive: malformed, manifest: demoManifest } }), + makeSlimServicesSource(() => malformedDemo) + .materialize(request, destination, malformedDemo.sha256) .pipe(Effect.exit), ); expect(errorOf(failed)).toBeInstanceOf(PreparationError); @@ -561,28 +602,15 @@ describe("slim-services artifact source", () => { { name: "bin/escape", link: "../../outside" }, ]), ); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`)); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-link-escape-", }); const failed = yield* withFetch( - fetcher, - makeSlimServicesSource(() => artifact) - .materialize(request, destination, expected) + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo) + .materialize(request, destination, demo.sha256) .pipe(Effect.exit), ); expect(errorOf(failed)).toBeInstanceOf(PreparationError); @@ -594,20 +622,12 @@ describe("slim-services artifact source", () => { it.live("interrupts an in-flight download without publishing a staging artifact", () => Effect.scoped( Effect.gen(function* () { + const demo = yield* pinned(new Uint8Array()); const started = yield* Deferred.make<void>(); let signal: AbortSignal | undefined; const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response("0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"), - ); + if (requestUrl(input).endsWith("manifest.json")) + return Promise.resolve(new Response(demoManifest)); signal = init?.signal ?? undefined; Deferred.doneUnsafe(started, Effect.void); return waitForAbort(signal); @@ -619,11 +639,7 @@ describe("slim-services artifact source", () => { const fiber = yield* Effect.forkChild( withFetch( fetcher, - makeSlimServicesSource(() => artifact).materialize( - request, - destination, - "0".repeat(64), - ), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ), { startImmediately: true }, ); @@ -635,39 +651,25 @@ describe("slim-services artifact source", () => { ), ); - it.live("rejects a streamed checksum mismatch before publishing and retries cleanly", () => + it.live("rejects an archive that misses its pin before publishing and retries cleanly", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - let validChecksum = false; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response( - `${validChecksum ? expected : "0".repeat(64)} demo-v1.0.0-linux-amd64.tar.zst\n`, - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); + let current: SlimServicesArtifact = { ...demo, sha256: "0".repeat(64) }; const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-integrity-", }); - const source = makeSlimServicesSource(() => artifact); - const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const store = yield* makeArtifactStore({ + cacheRoot: root, + source: makeSlimServicesSource(() => current), + }); + const fetcher = serving({ "example.test": { archive, manifest: demoManifest } }); const failed = yield* withFetch(fetcher, store.prepare(request).pipe(Effect.exit)); expect(Exit.isFailure(failed)).toBe(true); expect(yield* fs.exists(`${root}/demo/v1`)).toBe(false); - validChecksum = true; + current = demo; const prepared = yield* withFetch(fetcher, store.prepare(request)); expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); }).pipe(Effect.provide(NodeServices.layer)), @@ -677,21 +679,13 @@ describe("slim-services artifact source", () => { it.live("cancels a streamed response after transfer starts and removes its staging file", () => Effect.scoped( Effect.gen(function* () { + const demo = yield* pinned(new Uint8Array()); const started = yield* Deferred.make<void>(); let signal: AbortSignal | undefined; let canceled = false; const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response("0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"), - ); + if (requestUrl(input).endsWith("manifest.json")) + return Promise.resolve(new Response(demoManifest)); signal = init?.signal ?? undefined; let pulls = 0; const released = Deferred.makeUnsafe<void>(); @@ -718,11 +712,7 @@ describe("slim-services artifact source", () => { const fiber = yield* Effect.forkChild( withFetch( fetcher, - makeSlimServicesSource(() => artifact).materialize( - request, - destination, - "0".repeat(64), - ), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ), { startImmediately: true }, ); @@ -739,8 +729,7 @@ describe("slim-services artifact source", () => { Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const demo = yield* pinned(archive); const started = yield* Deferred.make<void>(); let destroyed = false; const decompressor: ZstdDecompressor = { @@ -752,27 +741,15 @@ describe("slim-services artifact source", () => { }); }), }; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`)); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-zstd-" }); const fiber = yield* Effect.forkChild( withFetch( - fetcher, - makeSlimServicesSource(() => artifact, { decompressor }).materialize( + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo, { decompressor }).materialize( request, destination, - expected, + demo.sha256, ), ), { startImmediately: true }, @@ -790,114 +767,106 @@ describe("slim-services artifact source", () => { Effect.gen(function* () { const longName = `bin/${"long-function-name-".repeat(8)}.js`; const archive = yield* compress(paxTar(longName)); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-pax-" }); yield* withFetch( - fetcher, - makeSlimServicesSource(() => artifact).materialize(request, destination, expected), + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ); expect(yield* fs.exists(`${destination}/${longName}`)).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ), ); - - it.live("publishes the extracted slim artifact through the verified store", () => - Effect.scoped( - Effect.gen(function* () { - const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const checksums = `${expected} demo-v1.0.0-linux-amd64.tar.zst\n`; - let checksumRequests = 0; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) { - checksumRequests += 1; - return Promise.resolve(new Response(checksums)); - } - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - const source = makeSlimServicesSource(() => artifact); - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-" }); - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const prepared = yield* withFetch(fetcher, store.prepare(request)); - expect(prepared.outcome).toBe("downloaded"); - expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); - expect(yield* fs.exists(`${prepared.path}/.artifact.json`)).toBe(true); - expect(checksumRequests).toBe(1); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); }); describe("native artifact catalog", () => { - it.live("verifies an S3 download against GHCR when GitHub release assets are blocked", () => + const linux = { os: "linux", arch: "x64" }; + const s3 = "supabase-cli-artifacts.s3.us-east-1.amazonaws.com"; + + it.live( + "requests release-versioned assets from GitHub, then S3, and rejects unpinned bytes", + () => + Effect.scoped( + Effect.gen(function* () { + const { version, releaseVersion } = yield* resolveArtifact({ service: "rest" }); + expect(releaseVersion).toMatch(/-r(0|[1-9][0-9]*)$/u); + expect(releaseVersion.startsWith(`${version}-r`)).toBe(true); + const asset = `postgrest-${releaseVersion}-linux-amd64`; + const archive = yield* compress(tar("bin/postgrest", "postgrest")); + const manifest = manifestBytes(releaseVersion, "postgrest"); + const requested: string[] = []; + const fetcher = serving( + { "github.com": { archive, manifest }, [s3]: { archive, manifest } }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-catalog-" }); + const failed = yield* withFetch( + fetcher, + prepareNativeArtifact({ service: "rest" }, cacheRoot, linux).pipe(Effect.exit), + ); + const github = `https://github.com/supabase/slim-services/releases/download/postgrest-${releaseVersion}`; + const bucket = `https://${s3}/postgrest/${releaseVersion}`; + expect(requested).toEqual([ + `${github}/${asset}.manifest.json`, + `${bucket}/${asset}.manifest.json`, + ]); + const message = errorOf(failed)?.message ?? ""; + expect(message).toContain( + `${github}/${asset}.tar.zst (Slim-services manifest does not match its pin`, + ); + expect(message).toContain( + `${bucket}/${asset}.tar.zst (Slim-services manifest does not match its pin`, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("keys the native cache by release version and ignores a legacy upstream entry", () => Effect.scoped( Effect.gen(function* () { - const { version } = yield* resolveArtifact({ service: "rest" }); - const asset = `postgrest-${version}-linux-amd64`; - const archive = yield* compress(tar("bin/postgrest", "postgrest")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const served: string[] = []; - const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.startsWith("https://github.com/")) - return Promise.resolve(new Response("", { status: 403 })); - served.push(url); - const registry = registryResponse( - input, - init, - expected, - "ghcr.io", - "supabase/cli/postgrest", - `${version}-native-linux-amd64`, - ); - if (registry !== undefined) return Promise.resolve(registry); - if (url.endsWith(".manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "postgrest", version, target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const resolved = yield* resolveArtifact({ service: "rest" }); const fs = yield* FileSystem.FileSystem; - const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-catalog-" }); - const prepared = yield* withFetch( - fetcher, - prepareNativeArtifact({ service: "rest" }, cacheRoot, { os: "linux", arch: "x64" }), + const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-cache-" }); + const requested: string[] = []; + const seed = (version: string, content: string) => + makeArtifactStore({ cacheRoot, source: fixtureSource(content) }).pipe( + Effect.flatMap((store) => + withFetch( + serving({}, requested), + store.prepare({ + key: `slim-services/postgrest/${version}/linux-amd64`, + requiredRuntimePaths: resolved.requiredRuntimePaths, + executablePath: resolved.executablePath, + }), + ), + ), + ); + const prepare = withFetch( + serving({}, requested), + prepareNativeArtifact({ service: "rest" }, cacheRoot, linux), ); - expect(yield* fs.readFileString(prepared.executable)).toBe("postgrest"); - const bucket = `https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/postgrest/${version}`; - expect(served).toEqual([ - "https://ghcr.io/token?scope=repository:supabase/cli/postgrest:pull&service=ghcr.io", - `https://ghcr.io/v2/supabase/cli/postgrest/manifests/${version}-native-linux-amd64`, - `${bucket}/${asset}.manifest.json`, - `${bucket}/${asset}.tar.zst`, - ]); + + yield* seed(resolved.version, "legacy"); + expect(Exit.isFailure(yield* prepare.pipe(Effect.exit))).toBe(true); + expect(requested).not.toEqual([]); + + const seeded = yield* seed(resolved.releaseVersion, "pinned"); + requested.length = 0; + const prepared = yield* prepare; + expect(prepared.root).toBe(seeded.path); + expect(yield* fs.readFileString(prepared.executable)).toBe("pinned"); + expect(requested).toEqual([]); }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it("catalog has no placeholder pins", () => { + const digests = catalogPins().flatMap(({ pin }) => [ + pin.image.slice(pin.image.lastIndexOf(":") + 1), + ...Object.values(pin.natives).flatMap((native) => [native.archive, native.manifest]), + ]); + expect(digests).not.toContain("0".repeat(64)); + }); }); diff --git a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts index 4d3bbc131e..a89e190335 100644 --- a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts +++ b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts @@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Crypto, Data, Effect, Exit, FileSystem, Path, Ref, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolveArtifact } from "../Artifacts.ts"; +import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; import { makeContainerRuntime } from "../runtime/Container.ts"; import { makeDockerDatabaseStorage } from "../storage/DockerDatabaseStorage.ts"; import { makeDockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; @@ -10,7 +10,8 @@ import { shellQuote } from "../storage/DockerSnapshotBackend.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; import { makeDatabaseSnapshots, type SnapshotScope } from "./DatabaseSnapshot.ts"; -const version = "17.6.1.173"; +// Derived from the real catalog (not hardcoded), so a Postgres pin bump never makes this go stale. +const version = postgresVersion("17"); class ShellError extends Data.TaggedError("ShellError")<{ readonly message: string }> {} @@ -510,8 +511,10 @@ for (const { name, make } of engines) const saved = yield* shell(`${tool}cat ${descriptor}`); const target = yield* engine.instance("target"); + // Any version different from `version` proves the mismatch check; this one is a + // synthetic sentinel, not a real catalog pin, so it stays correct across catalog bumps. yield* shell( - `printf '%s' ${shellQuote(saved.replace(version, "15.14.1.173"))} > ${descriptor}`, + `printf '%s' ${shellQuote(saved.replace(version, "0.0.0-version-mismatch"))} > ${descriptor}`, ); expect(yield* target.restoreSnapshot("key")).toBe(false); expect(yield* contents(target.data)).toBe(""); diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index 6e7484b6d7..9f14cc70ce 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -24,6 +24,7 @@ import { systemError } from "effect/PlatformError"; import * as Net from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- the collision fixture owns a local HTTP listener. import * as NodeHttp from "node:http"; +import { catalogPins, resolveArtifact, type ServiceKind } from "../Artifacts.ts"; import { makeArtifactStore, type ArtifactRequest, @@ -48,12 +49,21 @@ import * as Pooler from "./Pooler.ts"; type TestCreation = RecipeCreation<"rest", Record<string, never>> & { readonly service: "rest"; - readonly version: "v16.2"; + readonly version: string; }; +// The real catalog's default postgrest pin, not hardcoded — `makeProcessRecipe` resolves this +// through the real catalog, which would otherwise fail once a bump moves past a literal. +const postgrestVersion = catalogPins().find( + (entry) => entry.sourceService === "postgrest" && entry.isDefault, +)?.pin.upstreamVersion; +if (postgrestVersion === undefined) { + throw new Error("no default postgrest catalog pin found"); +} + const creation: TestCreation = { service: "rest", - version: "v16.2", + version: postgrestVersion, config: {}, }; @@ -459,6 +469,7 @@ const platform = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); const nativeFixtureArtifact = Effect.fn(function* ( cacheRoot: string, artifact: { + readonly service: ServiceKind; readonly name: string; readonly executablePath: string; readonly files: Readonly<Record<string, string>>; @@ -475,8 +486,9 @@ const nativeFixtureArtifact = Effect.fn(function* ( : undefined; if (target === undefined) return yield* Effect.fail(`Unsupported test platform: ${platformName}`); + const { releaseVersion } = yield* resolveArtifact({ service: artifact.service }); const request: ArtifactRequest = { - key: `slim-services/${artifact.name}/${target}`, + key: `slim-services/${artifact.name}/${releaseVersion}/${target}`, requiredRuntimePaths: Object.keys(artifact.files), executablePath: artifact.executablePath, }; @@ -506,7 +518,8 @@ const nativeFixtureArtifact = Effect.fn(function* ( const nativeRestArtifact = (cacheRoot: string, program = "") => nativeFixtureArtifact(cacheRoot, { - name: "postgrest/v16.2", + service: "rest", + name: "postgrest", executablePath: "bin/postgrest", files: { "bin/postgrest": `#!${process.execPath}\n${program}` }, }); @@ -530,7 +543,8 @@ const nativePoolerArtifact = (cacheRoot: string) => { `});\n`; const oneShot = `#!${process.execPath}\nprocess.exit(0);\n`; return nativeFixtureArtifact(cacheRoot, { - name: "pooler/v2.9.12", + service: "pooler", + name: "pooler", executablePath: "bin/server", files: { "bin/server": server, "bin/prepare": oneShot, "bin/provision-tenant": oneShot }, }); diff --git a/packages/stack/src/services/Vector.ts b/packages/stack/src/services/Vector.ts index da59642e4b..aeab546a93 100644 --- a/packages/stack/src/services/Vector.ts +++ b/packages/stack/src/services/Vector.ts @@ -20,8 +20,35 @@ export interface Endpoints extends Schema.Schema.Type<typeof Endpoints> {} export const Creation = serviceCreation("vector", Config, Endpoints); export interface Creation extends Schema.Schema.Type<typeof Creation> {} -// Vector has no API flag or env var, so the recipe loads this alongside the pipeline config. -const apiConfig = 'api:\n enabled: true\n address: "${VECTOR_API_ADDRESS}"\n'; +/** + * Vector has no API flag or env var, so the recipe loads this alongside the pipeline config. + * `address` is templated directly into the file content at write time (not through Vector's own + * `${VAR}` config interpolation, which 0.58 disabled by default) — this is the only stack-owned + * config Vector loads that ever varied per launch, so no interpolation flag is needed at all. + */ +const apiConfigFor = (address: string | undefined): string => + address === undefined + ? "api:\n enabled: false\n" + : `api:\n enabled: true\n address: "${address}"\n`; + +/** + * Every `${VAR}` name the recipe's own `env` sets and documents for a pipeline config + * (`Config.analyticsUrl`/`apiKey`, mirrored into `LOGFLARE_URL`/`LOGFLARE_PRIVATE_ACCESS_TOKEN`). + * A caller-supplied pipeline (`Config.configPath`) may reference these the same way the recipe's + * own API config used to. `renderKnownPlaceholders` substitutes only this closed, recipe-owned + * set directly into the file at write time — never a caller's or the process's arbitrary + * environment — so a caller config keeps working on Vector 0.58 without the recipe ever passing + * `--dangerously-allow-env-var-interpolation` (which would expose every env var, not just these). + */ +const renderKnownPlaceholders = ( + content: string, + values: Readonly<Record<string, string | undefined>>, +): string => + Object.entries(values).reduce( + (rendered, [name, value]) => + value === undefined ? rendered : rendered.replaceAll(`\${${name}}`, value), + content, + ); // Vector requires at least one source and sink; the default forwards nothing. const defaultPipelineConfig = [ @@ -73,7 +100,10 @@ const makeSpec = ( const configRoot = path.join(instanceRoot, "runtime", "vector"); const apiConfigPath = path.join(configRoot, "vector-api.yaml"); const defaultPipelinePath = path.join(configRoot, "vector.yaml"); - const pipelinePath = (creation: Creation) => creation.config.configPath ?? defaultPipelinePath; + const renderedPipelinePath = path.join(configRoot, "vector.rendered.yaml"); + /** What Vector actually loads: a caller's file is rendered to a recipe-owned copy first. */ + const resolvedPipelinePath = (creation: Creation) => + creation.config.configPath === undefined ? defaultPipelinePath : renderedPipelinePath; const ownedInstance = (operation: string) => /^[a-zA-Z0-9_-]+$/u.test(instanceId) ? Effect.void @@ -83,30 +113,47 @@ const makeSpec = ( executable: "bin/vector", ports: { http: 9001 }, healthPath: "/health", - env: (creation, endpoints, container) => + env: (creation, _endpoints, _container) => requiredInput("vector", "analyticsUrl", creation.config.analyticsUrl).pipe( - Effect.map((analyticsUrl) => { - const http = endpoints.get("http"); - return { - ...(http === undefined - ? {} - : { VECTOR_API_ADDRESS: `${container ? "0.0.0.0" : "127.0.0.1"}:${http.port}` }), - LOGFLARE_URL: analyticsUrl, - ...(creation.config.apiKey === undefined - ? {} - : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), - }; - }), + Effect.map((analyticsUrl) => ({ + LOGFLARE_URL: analyticsUrl, + ...(creation.config.apiKey === undefined + ? {} + : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), + })), ), - args: (creation, _endpoints, context) => - Effect.succeed( - context.container + args: (creation, endpoints, context) => + Effect.gen(function* () { + const http = endpoints.get("http"); + const address = + http === undefined + ? undefined + : `${context.container ? "0.0.0.0" : "127.0.0.1"}:${http.port}`; + yield* writeAtomically(fs, path, apiConfigPath, apiConfigFor(address)); + if (creation.config.configPath !== undefined) { + const source = yield* fs.readFileString(creation.config.configPath).pipe( + Effect.mapError( + (cause) => + new ServiceError({ + operation: "prepare", + message: "Unable to read Vector configPath", + cause, + }), + ), + ); + const rendered = renderKnownPlaceholders(source, { + LOGFLARE_URL: creation.config.analyticsUrl, + LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey, + }); + yield* writeAtomically(fs, path, renderedPipelinePath, rendered); + } + return context.container ? ["--config", containerPipelinePath, "--config", containerApiPath] - : ["--config", pipelinePath(creation), "--config", apiConfigPath], - ), + : ["--config", resolvedPipelinePath(creation), "--config", apiConfigPath]; + }), mounts: (creation) => Effect.succeed([ - { source: pipelinePath(creation), target: containerPipelinePath, readOnly: true }, + { source: resolvedPipelinePath(creation), target: containerPipelinePath, readOnly: true }, { source: apiConfigPath, target: containerApiPath, readOnly: true }, ]), startupCommands: [], @@ -136,6 +183,7 @@ const makeSpec = ( const owned = yield* Effect.all([ canonical(apiConfigPath), canonical(defaultPipelinePath), + canonical(renderedPipelinePath), ]).pipe( Effect.mapError( (cause) => @@ -152,7 +200,9 @@ const makeSpec = ( message: "Vector configPath must not point at a stack-owned Vector config file", }); } - yield* writeAtomically(fs, path, apiConfigPath, apiConfig); + // A safe placeholder until `args` writes the real address — the listening port isn't + // known until endpoints are reserved for an actual launch, which happens after `prepare`. + yield* writeAtomically(fs, path, apiConfigPath, apiConfigFor(undefined)); if (callerPath === undefined) yield* writeAtomically(fs, path, defaultPipelinePath, defaultPipelineConfig); }), @@ -162,6 +212,7 @@ const makeSpec = ( yield* ownedInstance("destroy"); yield* fs.remove(apiConfigPath, { force: true }); yield* fs.remove(defaultPipelinePath, { force: true }); + yield* fs.remove(renderedPipelinePath, { force: true }); if (yield* fs.exists(configRoot)) for (const entry of yield* fs.readDirectory(configRoot)) if (entry.startsWith(temporaryPrefix)) diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 7a0be70b33..14021aa972 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -1058,7 +1058,9 @@ describe("Docker database storage", { timeout: 120_000 }, () => { yield* fs.writeFileString(path.join(nativeRoot, "data", "PG_VERSION"), "17\n"); yield* fs.writeFileString( path.join(nativeRoot, ".supabase-database-ready.json"), - '{"version":"17.6.1.173","runtime":"native","profile":"supabase"}', + // `makeDatabaseSnapshots` below resolves `version: "17"` through the real catalog + // (`postgresVersion`), so the ready marker must carry that same resolved version. + `{"version":"${postgresVersion("17")}","runtime":"native","profile":"supabase"}`, ); const nativeSnapshots = yield* makeDatabaseSnapshots({ instanceRoot: nativeRoot, From 985e0ae9e9e9f364630a00dc45ea300f5f5019d8 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau <avallete@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:03:55 +0000 Subject: [PATCH 70/71] fix(stack): reuse keep-alive upstream connections in the HTTP gateway (#6925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR The local stack's API gateway no longer opens a new upstream connection for every request. Safe, bodyless requests (GET, HEAD, OPTIONS, TRACE) reuse pooled keep-alive connections, so sustained read traffic stops producing waves of `502 Bad Gateway`. Writes keep one connection per request so they are never sent twice. ## Before ```mermaid flowchart LR C[Client request] --> G[Gateway] G --> N["New upstream connection<br/>(every request)"] N --> U[Upstream] U --> X[Connection closed] X --> T["TIME_WAIT piles up"] T --> F["502 waves:<br/>EADDRNOTAVAIL native,<br/>forwarder resets on Docker"] ``` ## After ```mermaid flowchart LR C[Client request] --> R{"Safe method<br/>and no body?"} R -- yes --> P["Pooled keep-alive<br/>connection"] R -- no --> F["Fresh connection,<br/>never replayed"] P --> E{"Failed before<br/>a response?"} E -- yes --> O["One retry on a<br/>fresh connection"] E -- no --> D[Response] ``` ## Why Since #6897 the gateway forwarded with `agent: false`. Under sustained concurrent `GET /rest/v1/` traffic, closed connections pile up in `TIME_WAIT`: the native runtime fails with `connect EADDRNOTAVAIL` once the ephemeral range is full, and on Docker Desktop the port forwarder starts resetting connections (`socket hang up`, `ECONNRESET`). The gateway then answers 502 until `TIME_WAIT` drains. Details and measurements are in #6922. #6897 moved away from pooling because Studio's MCP route closes its connection shortly after each POST, so a pooled POST could land on a closing connection and could not be replayed. Those POSTs now stay on fresh connections. ## What changed - One keep-alive agent per gateway, released with it; sockets per upstream stay unbounded so long-lived streamed responses never queue other requests, and idle sockets close after 4 s, before Node upstreams' keep-alive timeout. - Only safe, bodyless requests use the pool. Every other request keeps a fresh connection and is never replayed, since upstreams do not deduplicate writes. - A single retry, always on a fresh connection, for a safe, bodyless request whose upstream fails before responding. - Known limitation: write-heavy traffic (PostgREST writes and RPC, Storage uploads, function invocations) still opens one connection per request. Closes #6922 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: avallete <andrew@snaplet.dev> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../stack/src/HttpProxy.integration.test.ts | 238 ++++++++++++++---- packages/stack/src/HttpProxy.ts | 68 ++--- 2 files changed, 231 insertions(+), 75 deletions(-) diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index bec4715c09..8e6398d412 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -38,6 +38,7 @@ class HttpProxyTestError extends Data.TaggedError("HttpProxyTestError")<{ }> {} const captureErrors = captureLogs(["Error"]); +const captureWarnings = captureLogs(["Error", "Warn"]); /** Upstream that resets its first `drops` accepted connections without responding. */ const droppingBackend = (drops: number) => { @@ -581,11 +582,7 @@ it.live("retries a bodyless request once when the upstream drops the connection }), ).pipe( Effect.provide( - Layer.mergeAll( - NodeHttpClient.layerNodeHttp, - NodeServices.layer, - captureLogs(["Error", "Warn"])(logs), - ), + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), ), ); }); @@ -617,50 +614,63 @@ it.live("does not replay a request with a body when the upstream drops the conne ); }); +/** + * Keep-alive upstream that answers once per connection and resets any reused connection; + * connections after `answered` are dropped unanswered. + */ +const oneRequestPerConnectionBackend = (answered = Number.POSITIVE_INFINITY) => { + let connections = 0; + const sockets = new Set<Socket>(); + const server = createTcpServer((socket) => { + connections += 1; + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + if (connections > answered) { + socket.destroy(); + return; + } + let buffered = Buffer.alloc(0); + let bodyEnd: number | undefined; + let replied = false; + socket.on("data", (chunk: Buffer) => { + if (replied) { + socket.resetAndDestroy(); + return; + } + buffered = Buffer.concat([buffered, chunk]); + if (bodyEnd === undefined) { + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const contentLength = Number( + /content-length:\s*(\d+)/iu.exec( + buffered.subarray(0, headerEnd).toString("latin1"), + )?.[1] ?? 0, + ); + bodyEnd = headerEnd + 4 + contentLength; + } + if (buffered.length < bodyEnd) return; + replied = true; + socket.write("HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok"); + }); + }); + return { + connections: () => connections, + listen: listen(server, { + beforeClose: () => { + for (const socket of sockets) socket.destroy(); + }, + }), + }; +}; + it.live( "succeeds a second POST when a keep-alive backend only answers the first request per connection", () => Effect.scoped( Effect.gen(function* () { - let connections = 0; - const sockets = new Set<Socket>(); - // Keeps each connection open after answering, then resets it if a second request reuses it. - const backend = createTcpServer((socket) => { - connections += 1; - sockets.add(socket); - socket.on("error", () => {}); - socket.on("close", () => sockets.delete(socket)); - let buffered = Buffer.alloc(0); - let bodyEnd: number | undefined; - let answered = false; - socket.on("data", (chunk: Buffer) => { - if (answered) { - socket.resetAndDestroy(); - return; - } - buffered = Buffer.concat([buffered, chunk]); - if (bodyEnd === undefined) { - const headerEnd = buffered.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const contentLength = Number( - /content-length:\s*(\d+)/iu.exec( - buffered.subarray(0, headerEnd).toString("latin1"), - )?.[1] ?? 0, - ); - bodyEnd = headerEnd + 4 + contentLength; - } - if (buffered.length < bodyEnd) return; - answered = true; - socket.write( - "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok", - ); - }); - }); - const address = yield* listen(backend, { - beforeClose: () => { - for (const socket of sockets) socket.destroy(); - }, - }); + const backend = oneRequestPerConnectionBackend(); + const address = yield* backend.listen; const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); yield* proxy.setRoutes([{ id: "mcp", prefix: "/", target: Effect.succeed(address) }]); const first = yield* request(proxy.port, "/mcp", new TextEncoder().encode("first-body")); @@ -669,11 +679,149 @@ it.live( const second = yield* request(proxy.port, "/mcp", new TextEncoder().encode("second-body")); expect(second.status).toBe(200); expect(new TextDecoder().decode(second.body)).toBe("ok"); - expect(connections).toBe(2); + expect(backend.connections()).toBe(2); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); +it.live( + "retries a GET once on a fresh connection when its pooled connection resets unanswered", + () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const backend = oneRequestPerConnectionBackend(); + const address = yield* backend.listen; + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "studio", prefix: "/", target: Effect.succeed(address) }]); + const first = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(first.status).toBe(200); + const second = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(second.status).toBe(200); + expect(new TextDecoder().decode(second.body)).toBe("ok"); + expect(backend.connections()).toBe(2); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Route studio GET upstream failed before responding, retrying"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), + ), + ); + }, +); + +it.live("returns a gateway error when the fresh retry after a pooled reset also fails", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const backend = oneRequestPerConnectionBackend(1); + const address = yield* backend.listen; + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "studio", prefix: "/", target: Effect.succeed(address) }]); + const first = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(first.status).toBe(200); + const second = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(second.status).toBe(502); + expect(backend.connections()).toBe(2); + expect(logs).toHaveLength(2); + expect(logs[0]).toContain("Route studio GET upstream failed before responding, retrying"); + expect(logs[1]).toContain("Route studio request failed"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), + ), + ); +}); + +it.live("delivers a keyed POST once when the upstream resets after reading its body", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const bodies: Array<string> = []; + let connections = 0; + // Answers GETs on a keep-alive connection; resets after reading a POST body in full. + const backend = createTcpServer((socket) => { + connections += 1; + socket.on("error", () => {}); + let buffered = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const head = buffered.subarray(0, headerEnd).toString("latin1"); + const bodyEnd = headerEnd + 4 + Number(/content-length:\s*(\d+)/iu.exec(head)?.[1] ?? 0); + if (buffered.length < bodyEnd) return; + const body = buffered.subarray(headerEnd + 4, bodyEnd).toString(); + buffered = buffered.subarray(bodyEnd); + if (head.startsWith("GET ")) { + socket.write( + "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok", + ); + return; + } + bodies.push(body); + socket.resetAndDestroy(); + }); + }); + const address = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "rest", prefix: "/", target: Effect.succeed(address) }]); + const pooled = yield* request(proxy.port, "/rest/v1/", new Uint8Array(), {}, "GET"); + expect(pooled.status).toBe(200); + const post = yield* request(proxy.port, "/rest/v1/rpc", new TextEncoder().encode("insert"), { + "idempotency-key": "insert", + }); + expect(post.status).toBe(502); + expect(bodies).toEqual(["insert"]); + expect(connections).toBe(2); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Route rest request failed"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), + ), + ); +}); + +it.live("reuses pooled upstream connections across thousands of concurrent requests", () => + Effect.scoped( + Effect.gen(function* () { + let connections = 0; + const backend = createServer((incoming, outgoing) => { + incoming.resume(); + incoming.once("end", () => outgoing.end(incoming.url)); + }); + backend.on("connection", () => { + connections += 1; + }); + const address = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "rest", prefix: "/", target: Effect.succeed(address) }]); + const responses = yield* Effect.forEach( + Array.from({ length: 3000 }, (_, index) => index), + (index) => + request(proxy.port, `/rest/v1/items?id=eq.${index}`, new Uint8Array(), {}, "GET").pipe( + Effect.map((response) => ({ + index, + status: response.status, + body: new TextDecoder().decode(response.body), + })), + ), + { concurrency: 16 }, + ); + expect( + responses.filter( + ({ index, status, body }) => status !== 200 || body !== `/rest/v1/items?id=eq.${index}`, + ), + ).toEqual([]); + expect(connections).toBeLessThan(100); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live( "does not replay a bodyless non-idempotent request when the upstream drops the connection", () => { diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index 4b4e5f0485..d2ae57d119 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -1,7 +1,8 @@ -import { Data, Effect, FiberSet, Ref, Schedule, Scope } from "effect"; +import { Data, Effect, FiberSet, Ref, Scope } from "effect"; import { PortError } from "./Ports.ts"; import type { BackendAddress, ProxyError } from "./Proxy.ts"; import { + Agent, createServer, request as upstreamRequest, type IncomingMessage, @@ -74,6 +75,10 @@ const hasBody = (request: IncomingMessage) => request.headers["transfer-encoding"] !== undefined || Number(request.headers["content-length"] ?? 0) > 0; +// A write can commit before its connection resets, so only safe, bodyless requests are resent. +const isReplayable = (request: IncomingMessage) => + safeMethods.has(request.method ?? "GET") && !hasBody(request); + const headersFor = (headers: IncomingMessage["headers"]) => Object.fromEntries( Object.entries(headers).filter( @@ -225,30 +230,24 @@ const connectInterruptibly = Effect.fn("HttpProxy.connect")((address: BackendAdd }), ); -// Mirrors nginx `proxy_next_upstream error`: a backend that drops a fresh connection before -// answering gets one more attempt, but only when nothing sent to the client or upstream would -// need replaying. -const retryOnce = (request: IncomingMessage, response: ServerResponse, route: HttpRoute) => - Schedule.recurs(1).pipe( - Schedule.setInputType<HttpProxyError | HttpProxyDisconnected>(), - Schedule.while( - ({ input }) => - input._tag === "HttpProxyError" && - input.responded === false && - !response.destroyed && - safeMethods.has(request.method ?? "GET") && - !hasBody(request), - ), - Schedule.tap(({ input }) => - Effect.logWarning( - `Route ${route.id} ${request.method ?? "GET"} upstream failed before responding, retrying`, - input, - ), - ), - ); +// Mirrors nginx `proxy_next_upstream error`: a backend that drops a connection before answering +// gets one more attempt, but only when nothing sent to the client or upstream would need replaying. +const isRetryable = + (request: IncomingMessage, response: ServerResponse) => + (error: HttpProxyError | HttpProxyDisconnected) => + error._tag === "HttpProxyError" && + error.responded === false && + !response.destroyed && + isReplayable(request); const forward = Effect.fn("HttpProxy.forward")( - (request: IncomingMessage, response: ServerResponse, route: HttpRoute, backend: BackendAddress) => + ( + request: IncomingMessage, + response: ServerResponse, + route: HttpRoute, + backend: BackendAddress, + agent: Agent | false, + ) => Effect.callback<void, HttpProxyError | HttpProxyDisconnected>((resume) => { let outgoing: ReturnType<typeof upstreamRequest> | undefined; let incoming: IncomingMessage | undefined; @@ -287,9 +286,7 @@ const forward = Effect.fn("HttpProxy.forward")( host: "path" in backend ? undefined : backend.host, port: "path" in backend ? undefined : backend.port, socketPath: "path" in backend ? backend.path : undefined, - // A reused upstream connection can be reset by a just-woken backend, and a body - // cannot be replayed. - agent: false, + agent, method: request.method, path: pathFor(request, route), // Bun ends a streamed upstream response early when the request says Connection: close. @@ -326,11 +323,16 @@ const forward = Effect.fn("HttpProxy.forward")( ); const proxyRequest = Effect.fn("HttpProxy.proxyRequest")( - (request: IncomingMessage, response: ServerResponse, route: HttpRoute) => + (request: IncomingMessage, response: ServerResponse, route: HttpRoute, agent: Agent) => Effect.gen(function* () { const backend = yield* Effect.raceFirst(route.target, disconnected(request, response)); - yield* forward(request, response, route, backend).pipe( - Effect.retry(retryOnce(request, response, route)), + yield* forward(request, response, route, backend, isReplayable(request) ? agent : false).pipe( + Effect.catchIf(isRetryable(request, response), (error) => + Effect.logWarning( + `Route ${route.id} ${request.method ?? "GET"} upstream failed before responding, retrying`, + error, + ).pipe(Effect.andThen(forward(request, response, route, backend, false))), + ), ); }), ); @@ -405,6 +407,12 @@ export const makeHttpProxy = (options: { }): Effect.Effect<HttpProxy, PortError, Scope.Scope> => Effect.gen(function* () { const routes = yield* Ref.make<ReadonlyArray<HttpRoute>>([]); + // Sockets per upstream stay unbounded so long-lived streamed responses never queue requests. + // Idle sockets close before Node upstreams' default 5 s keep-alive timeout can race a reuse. + const agent = yield* Effect.acquireRelease( + Effect.sync(() => new Agent({ keepAlive: true, timeout: 4_000 })), + (value) => Effect.sync(() => value.destroy()), + ); const runRequest = yield* FiberSet.makeRuntime(); const sockets = new Set<Socket>(); const server = createServer((request, response) => { @@ -422,7 +430,7 @@ export const makeHttpProxy = (options: { response.statusCode = 404; response.end("Not Found"); } else { - yield* proxyRequest(request, response, route).pipe( + yield* proxyRequest(request, response, route, agent).pipe( Effect.tapError((cause) => cause._tag === "HttpProxyDisconnected" ? Effect.void From 3cb948c5a70d31fbcb0fd1dcc616ee196a125cd0 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:05:36 +0000 Subject: [PATCH 71/71] chore(api): sync Management API OpenAPI spec (#6880) 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 | 8 ++++++++ packages/api/src/generated/openapi.json | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 73628b325e..b1b7bd52ac 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -10929,6 +10929,10 @@ export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", }), + custom_dns_name: Schema.String.annotate({ + description: + "The custom DNS name configured on the AWS VPC Lattice resource configuration.", + }), resource_access_manager_resource_config_id: Schema.optionalKey( Schema.String.annotate({ description: @@ -12898,6 +12902,10 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", }), + custom_dns_name: Schema.String.annotate({ + description: + "The custom DNS name configured on the AWS VPC Lattice resource configuration.", + }), resource_access_manager_resource_config_id: Schema.optionalKey( Schema.String.annotate({ description: diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 945dc9b7a0..60c3ec30c1 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -27277,6 +27277,10 @@ "type": "string", "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier." }, + "custom_dns_name": { + "type": "string", + "description": "The custom DNS name configured on the AWS VPC Lattice resource configuration." + }, "resource_access_manager_resource_config_id": { "description": "ID of the AWS VPC Lattice resource configuration backing this PrivateLink share.", "type": "string" @@ -27295,7 +27299,8 @@ "status", "shared_at", "database_type", - "database_identifier" + "database_identifier", + "custom_dns_name" ] } }, @@ -27400,6 +27405,10 @@ "type": "string", "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier." }, + "custom_dns_name": { + "type": "string", + "description": "The custom DNS name configured on the AWS VPC Lattice resource configuration." + }, "resource_access_manager_resource_config_id": { "description": "ID of the AWS VPC Lattice resource configuration backing this PrivateLink share.", "type": "string" @@ -27418,7 +27427,8 @@ "status", "shared_at", "database_type", - "database_identifier" + "database_identifier", + "custom_dns_name" ] } },