diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index 86cbdb35a3..24d76bbc96 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -1,5 +1,4 @@ import { StateManager, daemonLayer, resolveManagedStack, stopDaemon } from "@supabase/stack/effect"; -import { daemonEntryPoint } from "@supabase/stack"; import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; @@ -154,17 +153,14 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { }, }); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: stackState.name, - ...launchConfig, - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: stackState.name, + ...launchConfig, + }); yield* Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index 3e84cba86e..04d932246b 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -1,5 +1,4 @@ -import { unixHttpClientLayer } from "@supabase/stack"; -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; +import { DEFAULT_MANAGED_STACK_NAME, unixHttpClientLayer } from "@supabase/stack/effect"; import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts index 8cd70eb28f..144c71c390 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts @@ -1,6 +1,13 @@ -import { basename, dirname, resolve } from "node:path"; -import type { FunctionsConfig } from "@supabase/stack/effect"; -import { Effect, Option } from "effect"; +import { + inferFunctionsManifest, + loadDotEnvFile, + loadProjectConfig, + loadProjectEnvironment, + resolveProjectSubtree, +} from "@supabase/config"; +import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; +import { Effect, Option, Redacted } from "effect"; +import { basename, dirname, join, resolve } from "node:path"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; @@ -14,16 +21,80 @@ export interface FunctionsDevWatchPath { readonly names?: ReadonlyArray; } -export function toStackFunctionsConfig(opts: FunctionsDevConfigOptions): FunctionsConfig { - return { - envFile: Option.match(opts.envFile, { - onNone: () => undefined, - onSome: (path) => path, - }), - noVerifyJwt: opts.noVerifyJwt, - }; +function reveal(value: string | Redacted.Redacted): string { + return Redacted.isRedacted(value) ? Redacted.value(value) : value; +} + +function absoluteProjectPath(supabaseDir: string, path: string): string { + const withoutDotSlash = path.startsWith("./") ? path.slice(2) : path; + return resolve(supabaseDir, withoutDotSlash); } +export const resolveFunctionsBundle = Effect.fnUntraced(function* ( + opts: FunctionsDevConfigOptions, +) { + const projectHome = yield* ProjectHome; + const runtimeInfo = yield* RuntimeInfo; + const projectEnvironment = yield* loadProjectEnvironment({ + cwd: projectHome.projectRoot, + baseEnv: process.env, + }); + const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot); + const projectConfig = + projectEnvironment === null || loadedConfig === null + ? undefined + : { + ...loadedConfig.config, + functions: Object.fromEntries( + Object.entries( + yield* resolveProjectSubtree( + loadedConfig.config.functions, + projectEnvironment, + "functions", + ), + ).map(([name, config]) => [ + name, + { + ...config, + entrypoint: reveal(config.entrypoint), + import_map: reveal(config.import_map), + static_files: config.static_files.map(reveal), + env: Object.fromEntries( + Object.entries(config.env).map(([key, value]) => [key, reveal(value)]), + ), + }, + ]), + ), + }; + const manifest = yield* inferFunctionsManifest({ + cwd: projectHome.projectRoot, + ...(projectConfig === undefined ? {} : { config: projectConfig }), + }); + const envFilePath = Option.match(opts.envFile, { + onNone: () => join(projectHome.supabaseDir, "functions", ".env"), + onSome: (path) => resolve(runtimeInfo.cwd, path), + }); + + return { + env: yield* loadDotEnvFile(envFilePath), + functions: Object.entries(manifest) + .filter(([, config]) => config.enabled) + .map(([name, config]) => ({ + name, + verifyJWT: opts.noVerifyJwt ? false : config.verify_jwt, + entrypointPath: absoluteProjectPath(projectHome.supabaseDir, config.entrypoint), + importMapPath: + config.import_map === "" + ? null + : absoluteProjectPath(projectHome.supabaseDir, config.import_map), + staticFiles: config.static_files.map((path) => + absoluteProjectPath(projectHome.supabaseDir, path), + ), + env: config.env, + })), + } satisfies ResolvedFunctionsBundle; +}); + export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Option.Option) { const projectHome = yield* ProjectHome; const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index 983a7a6a5e..6467afdc29 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -7,11 +7,7 @@ import { join } from "node:path"; import { Effect, Exit, Layer, Option } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - functionsDevWatchPaths, - toStackFunctionsConfig, - type FunctionsDevConfigOptions, -} from "./functions-dev-config.ts"; +import { functionsDevWatchPaths, resolveFunctionsBundle } from "./functions-dev-config.ts"; import { FunctionsDevEdgeRuntimeDisabledError, resolveFunctionsDevEdgeRuntimeConfig, @@ -61,16 +57,60 @@ describe("functions dev config", () => { expect(connectOrStartFunctionsDevStack).toBeTypeOf("function"); }); - it("converts CLI options to stack Functions config", () => { - const opts: FunctionsDevConfigOptions = { - envFile: Option.some("./custom.env"), - noVerifyJwt: true, - }; + it.live("resolves project functions, environment and absolute paths before stack handoff", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(cwd, "supabase", "functions", "hello", "assets"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", "functions", "hello", "index.ts"), "export {};\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", "functions", "hello", "deno.json"), "{}\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "FUNCTION_VALUE=resolved-secret\n"), + ); + yield* Effect.tryPromise(() => writeFile(join(cwd, "custom.env"), "SHARED=custom\n")); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `[functions.hello] +verify_jwt = true +entrypoint = "./functions/hello/index.ts" +import_map = "./functions/hello/deno.json" +static_files = ["./functions/hello/assets/*"] + +[functions.hello.env] +FUNCTION_VALUE = "env(FUNCTION_VALUE)" +`, + ), + ); - expect(toStackFunctionsConfig(opts)).toEqual({ - envFile: "./custom.env", - noVerifyJwt: true, - }); + const bundle = yield* resolveFunctionsBundle({ + envFile: Option.some("./custom.env"), + noVerifyJwt: true, + }); + + expect(bundle).toEqual({ + env: { SHARED: "custom" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: join(cwd, "supabase", "functions", "hello", "index.ts"), + importMapPath: join(cwd, "supabase", "functions", "hello", "deno.json"), + staticFiles: [join(cwd, "supabase", "functions", "hello", "assets", "*")], + env: { FUNCTION_VALUE: "resolved-secret" }, + }, + ], + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); }); it.live("selects supabase and explicit env directory watch paths", () => { diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index c3a1e69722..d3e17f4314 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,4 +1,3 @@ -import { daemonEntryPoint } from "@supabase/stack"; import { connectLayer, daemonLayer, @@ -30,7 +29,7 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { startStackWithProgress } from "../../../stack/stack.shared.ts"; import { functionsDevWatchPaths, - toStackFunctionsConfig, + resolveFunctionsBundle, type FunctionsDevConfigOptions, type FunctionsDevWatchPath, } from "./functions-dev-config.ts"; @@ -68,19 +67,15 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio yield* ensureProjectStateIgnored(projectHome.projectRoot); const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: opts.stack, - edgeRuntime: opts.edgeRuntime, - functions: toStackFunctionsConfig(opts), - ...versionsFromContext(serviceVersionContext), - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: opts.stack, + edgeRuntime: opts.edgeRuntime, + ...versionsFromContext(serviceVersionContext), + }); const state = yield* stateManager.read(opts.stack); yield* stateManager.writeMetadata( @@ -180,9 +175,9 @@ function reloadEdgeRuntime( opts: FunctionsDevRuntimeOptions, edgeRuntime: EdgeRuntimeConfig, ) { - return stack.reloadEdgeRuntime({ - edgeRuntime, - functions: toStackFunctionsConfig(opts), + return Effect.gen(function* () { + const functions = yield* resolveFunctionsBundle(opts); + yield* stack.reloadEdgeRuntime({ edgeRuntime, functions }); }); } @@ -226,49 +221,51 @@ export const runFunctionsDevRuntime = Effect.fnUntraced(function* ( ...opts, edgeRuntime: edgeRuntimeState.config, }); - yield* ensureFunctionsDirectory(); - yield* reloadEdgeRuntime(stack, opts, edgeRuntimeState.config); - const info = yield* stack.getInfo(); - const watchPathList = yield* functionsDevWatchPaths(opts.envFile); - - yield* output.success("Edge Functions dev server is running.", { - functions_url: `${info.url}/functions/v1`, - }); - yield* output.info(`Functions URL: ${info.url}/functions/v1/`); - - const restartOnChange = watchPaths(watchPathList).pipe( - Stream.runForEach((change) => - Effect.gen(function* () { - const result = yield* applyWatchedChange(edgeRuntimeState, change); - if (result.action === "edge-runtime") { - yield* output.info("Edge runtime config changed. Restarting edge-runtime..."); - yield* reloadEdgeRuntime(stack, opts, result.state.config); + const restoreFunctions = startedByCommand + ? undefined + : yield* resolveFunctionsBundle({ envFile: Option.none(), noVerifyJwt: false }); + + yield* Effect.gen(function* () { + yield* ensureFunctionsDirectory(); + yield* reloadEdgeRuntime(stack, opts, edgeRuntimeState.config); + const info = yield* stack.getInfo(); + const watchPathList = yield* functionsDevWatchPaths(opts.envFile); + + yield* output.success("Edge Functions dev server is running.", { + functions_url: `${info.url}/functions/v1`, + }); + yield* output.info(`Functions URL: ${info.url}/functions/v1/`); + + const restartOnChange = watchPaths(watchPathList).pipe( + Stream.runForEach((change) => + Effect.gen(function* () { + const result = yield* applyWatchedChange(edgeRuntimeState, change); + if (result.action === "edge-runtime") { + yield* output.info("Edge runtime config changed. Restarting edge-runtime..."); + yield* reloadEdgeRuntime(stack, opts, result.state.config); + edgeRuntimeState = result.state; + return; + } edgeRuntimeState = result.state; - return; - } - edgeRuntimeState = result.state; - yield* output.info("Function files changed. Restarting edge-runtime..."); - yield* stack.reloadFunctions(toStackFunctionsConfig(opts)); - }).pipe( - Effect.catch((error) => - output.error(error instanceof Error ? error.message : String(error)), + yield* output.info("Function files changed. Restarting edge-runtime..."); + yield* stack.reloadFunctions({ functions: yield* resolveFunctionsBundle(opts) }); + }).pipe( + Effect.catch((error) => + output.error(error instanceof Error ? error.message : String(error)), + ), ), ), - ), - ); + ); - const logs = logEntryStream(stack).pipe(Stream.runForEach((event) => output.event(event))); - const shutdown = processControl.awaitShutdown; + const logs = logEntryStream(stack).pipe(Stream.runForEach((event) => output.event(event))); + const shutdown = processControl.awaitShutdown; - yield* Effect.raceFirst(Effect.raceFirst(restartOnChange, logs), shutdown).pipe( + yield* Effect.raceFirst(Effect.raceFirst(restartOnChange, logs), shutdown); + }).pipe( Effect.ensuring( - Effect.gen(function* () { - if (startedByCommand) { - yield* stack.dispose().pipe(Effect.ignore); - } else { - yield* stack.reloadFunctions({}).pipe(Effect.ignore); - } - }), + startedByCommand + ? stack.dispose().pipe(Effect.ignore) + : stack.reloadFunctions({ functions: restoreFunctions }).pipe(Effect.ignore), ), ); }); diff --git a/apps/cli/src/next/commands/functions/list/list.integration.test.ts b/apps/cli/src/next/commands/functions/list/list.integration.test.ts index cebc2aa46c..367f023ffe 100644 --- a/apps/cli/src/next/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/next/commands/functions/list/list.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/functions/new/new.integration.test.ts b/apps/cli/src/next/commands/functions/new/new.integration.test.ts index 0b6789a757..ad33aab809 100644 --- a/apps/cli/src/next/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/next/commands/functions/new/new.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index f9e554e959..95c1eb6e36 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { Effect, Exit, Fiber, Layer } from "effect"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index 4db1fc276d..4b675d13e9 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -7,7 +7,6 @@ import { stackMetadata, type StackMetadata, } from "@supabase/stack/effect"; -import { daemonEntryPoint } from "@supabase/stack"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts"; @@ -197,17 +196,14 @@ export const startCommand = Command.make("start", flags).pipe( yield* output.intro("Start local Supabase stack"); yield* ensureProjectStateIgnored(projectHome.projectRoot); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: flags.stack, - ...stackConfig, - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: flags.stack, + ...stackConfig, + }); const daemonState = yield* stateManager.read(flags.stack); const metadata = stackMetadata({ diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index 95ac5bc27a..f12be0f0ee 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { StackServiceState } from "@supabase/stack/effect"; import { Effect, Layer } from "effect"; import { status } from "./status.handler.ts"; diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index bcf7a8d46f..1f8167e436 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, Exit, Layer } from "effect"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { stop } from "./stop.handler.ts"; import { mockOutput, withEnv } from "../../../../tests/helpers/mocks.ts"; import { diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 69f9d35cbf..69be7da263 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { ProjectConfigStore } from "@supabase/config"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { Cause, Console, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; import { CliError, CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 885009f822..792c27fbb8 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -14,7 +14,7 @@ import { type StackMetadata, type StackState, } from "@supabase/stack/effect"; -import { UnixHttpClient } from "@supabase/stack"; +import { UnixHttpClient } from "@supabase/stack/testing"; import { Api } from "../../src/next/auth/api.service.ts"; import type { LoginSessionResponse, ProfileResponse } from "../../src/next/auth/api.service.ts"; import { Credentials } from "../../src/next/auth/credentials.service.ts"; diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 50283f76fa..9e7ed2e106 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -1,8 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { unixHttpClientLayer } from "@supabase/stack"; import { - DaemonServer, DEFAULT_VERSIONS, fullVersionManifest, type PartialVersionManifest, @@ -14,7 +12,9 @@ import { type StackInfo, type StackMetadata, type StackState, + unixHttpClientLayer, } from "@supabase/stack/effect"; +import { DaemonServer } from "@supabase/stack/testing"; import { Effect, Layer, ManagedRuntime, Option, Stream } from "effect"; import { spawn, type ChildProcess } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index aed5911431..e2b9475830 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -37,6 +37,7 @@ export { type ProjectEnvironment, type ResolvedProjectValue, type ResolveProjectOptions, + loadDotEnvFile, loadProjectEnvironment, resolveProjectSubtree, resolveProjectValue, diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index eb1def2642..28f4c5cd3a 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -166,6 +166,15 @@ function parseDotEnv( }); } +/** Parse one explicit dotenv file without applying ambient or project-local precedence. */ +export const loadDotEnvFile = Effect.fnUntraced(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(path))) { + return {}; + } + return yield* parseDotEnv(path, yield* fs.readFileString(path)); +}); + function applySource( target: Record, sources: Record, diff --git a/packages/process-compose/docs/architecture.md b/packages/process-compose/docs/architecture.md index 4e652d1237..e5f6635f8d 100644 --- a/packages/process-compose/docs/architecture.md +++ b/packages/process-compose/docs/architecture.md @@ -58,7 +58,9 @@ one of three conditions: `startService(name)` starts the requested definition and its transitive dependencies. `stopService(name)` and `restartService(name)` also include active dependents so a caller cannot -leave an already-running dependent attached to a restarted dependency. +leave an already-running dependent attached to a restarted dependency. The pure restart-closure +calculation preserves inactive connector services on a dependency path to an active descendant; +otherwise restarting the descendant without its connector would violate graph order. `updateServiceDefinition(name, replacement)` validates a graph with the replacement and then swaps the graph used for subsequent starts and restarts. It does not mutate a process generation that is @@ -110,7 +112,8 @@ redundant publications must compare before writing. `ServiceTransition` is the only normal path for observed-status changes. `applyEvent()` rejects illegal `(status, event)` pairs by returning `null`; `transition()` applies a legal event atomically through `SubscriptionRef.modifyEffect`. Races such as a late health callback during shutdown are -therefore ignored without corrupting state. +therefore ignored without corrupting state. The transition classification is keyed by every event +tag, so adding an event fails type checking until its legal source statuses are defined. ## Lifecycle of one process generation @@ -121,9 +124,9 @@ For each requested definition, `Orchestrator` runs this sequence: backoff, allowing a caller to reserve external resources. 3. Wait for dependencies, bounded by `dependencyTimeoutSeconds` (default: 120 seconds). 4. Transition to `Starting`. -5. Call `beforeSpawn` immediately before every spawn. The stack uses it to release a reserved port - as late as possible. For a supervised Docker command there is still a spawn-to-container-bind - window because no supervisor/container bind handshake exists. +5. Call `beforeSpawn` immediately before every spawn. A caller can use it to release a reserved port + as late as possible. For a supervised child there is still a spawn-to-bind window because no + supervisor/child bind handshake exists. 6. Spawn either the configured process or its optional supervisor. 7. Register a scoped finalizer before exposing `Running`. 8. Run `started` hooks sequentially. Only successful hooks allow `ProcessSpawned` to publish @@ -137,6 +140,10 @@ For each requested definition, `Orchestrator` runs this sequence: The service keeps the same state stream across restart generations. Restart backoff is `min(30 seconds, 2^(restartCount - 1))`. +A no-health-check, `restart: "no"` process is treated as one-shot work. A small isolated poll of +`ChildProcessHandle.isRunning` compensates for adapters that can report process completion before +their `exitCode` Effect becomes observable; it is not part of the general lifecycle loop. + ### Lifecycle hooks Hooks are caller-supplied Effects triggered on `started` or `healthy`. Hooks for one trigger run in @@ -212,9 +219,15 @@ In-process cleanup and orphan supervision solve different failure modes: owner's stdin closes, its PID disappears, the supervisor receives a termination signal, or the managed child exits while cleanup is configured. -`ExternalCleanupAction` currently supports removing a Docker container or a filesystem path. The -supervisor validates the decoded configuration and ignores individual cleanup failures so cleanup -remains best-effort and idempotent. +`ExternalCleanupAction` supports a shell-free `RunCommand` with an executable, argument array, and +optional timeout (default 5 seconds), plus `RemovePath` for filesystem cleanup. Cleanup commands +receive the managed child's sanitized environment, without the supervisor self-dispatch protocol +variables. The supervisor rejects a malformed decoded cleanup contract before spawning the child, +while individual execution failures remain best-effort. Callers must choose idempotent commands +because owner-loss signals can race. `RunCommand` actions execute serially, so their aggregate +duration can approach the sum of their individual timeouts. Each command runs in its own process +group on Unix, while Windows uses `taskkill /T`; a timeout therefore terminates the command tree +rather than only its root. Filesystem retry delays may overlap command execution. ## Supervisor runtime @@ -247,7 +260,8 @@ virtual filesystem. The package therefore uses three internal environment variab The CLI entrypoint calls `enableSupervisorSelfDispatchForCompiledBun()` before normal command dispatch, checks `isSupervisorRuntimeRequested()`, and invokes `runSupervisorRuntimeFromEnv()`. The supervisor removes all three variables before starting the managed command, preventing the -protocol from leaking recursively into a service. +protocol from leaking recursively into a service. Contract tests run the same encoded supervisor +configuration through both the source-file argument path and environment-based self-dispatch path. The stack daemon has a separate Supabase-owned marker, `SUPABASE_STACK_RUN_DAEMON`; it is documented in [the stack detach-mode guide](../../stack/docs/detach-mode.md#compiled-executable-re-entry). @@ -264,15 +278,13 @@ plus live per-service and merged `PubSub` streams. `historyAll` can filter by se Streams contain new entries only; callers explicitly request history when they need replay. When a process exits unexpectedly or becomes unhealthy, the orchestrator appends recent buffered -output to its diagnostics. `truncate` currently clears both a service's history and its entries in -the merged history; it has no production caller. +output to its diagnostics. ## Error model Graph construction can fail with `MissingDependencyError` or `CyclicDependencyError`. Lifecycle lookup uses `ServiceNotFoundError`; spawn preparation uses `SpawnError`; readiness uses -`ServiceReadyError`. Global shutdown timeout is logged and force-cleared, so the exported -`ShutdownTimeoutError` does not represent a current failure path. +`ServiceReadyError`. Global shutdown timeout is logged and force-cleared. ## Testing through Interfaces diff --git a/packages/process-compose/src/LogBuffer.ts b/packages/process-compose/src/LogBuffer.ts index 53a247098c..b561d7e6fe 100644 --- a/packages/process-compose/src/LogBuffer.ts +++ b/packages/process-compose/src/LogBuffer.ts @@ -24,7 +24,6 @@ export class LogBuffer extends Context.Service< limit?: number, services?: ReadonlyArray, ) => Effect.Effect>; - readonly truncate: (service: string) => Effect.Effect; } >()("process-compose/LogBuffer") { static layer = Layer.effect( @@ -97,15 +96,6 @@ export class LogBuffer extends Context.Service< return filtered.slice(-limit); }), - - truncate: (service) => - Effect.gen(function* () { - const { buffer } = yield* getOrCreate(service); - yield* Ref.set(buffer, []); - yield* Ref.update(globalBuffer, (entries) => - entries.filter((entry) => entry.service !== service), - ); - }), }; }), ); diff --git a/packages/process-compose/src/LogBuffer.unit.test.ts b/packages/process-compose/src/LogBuffer.unit.test.ts index b4e26774a8..c758785707 100644 --- a/packages/process-compose/src/LogBuffer.unit.test.ts +++ b/packages/process-compose/src/LogBuffer.unit.test.ts @@ -69,17 +69,6 @@ describe("LogBuffer", () => { }).pipe(Effect.provide(layer)); }); - it.live("truncate clears buffer", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - yield* log.append("svc", "stdout", "line1"); - yield* log.append("svc", "stdout", "line2"); - yield* log.truncate("svc"); - const entries = yield* log.history("svc"); - expect(entries).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); - it.live("subscribeAll receives entries from all services", () => Effect.gen(function* () { const log = yield* LogBuffer; diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts index dd1014cbd7..2a5f845e11 100644 --- a/packages/process-compose/src/Orchestrator.ts +++ b/packages/process-compose/src/Orchestrator.ts @@ -17,6 +17,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildGraph, type ResolvedGraph } from "./DependencyGraph.ts"; import { type HealthProbeCallbacks, runHealthProbe } from "./HealthProbe.ts"; import { LogBuffer } from "./LogBuffer.ts"; +import { restartClosureFor } from "./RestartClosure.ts"; import { decideRestart, type LifecycleCause, @@ -55,6 +56,8 @@ const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean => ); }; +// Some one-shot adapters report `isRunning: false` before their exit-code Effect is observable. +// Keep the compensating poll isolated here so the ordinary process-exit path remains event-driven. const waitForProcessToStop = (handle: { readonly isRunning: Effect.Effect; }): Effect.Effect => @@ -316,10 +319,10 @@ export class Orchestrator extends Context.Service< // Release external resources such as port reservations only // once dependencies are satisfied and spawning is imminent. - // For supervised Docker services, this still leaves a wider - // spawn-to-bind window because the supervisor starts before - // the container binds its published ports. Closing that gap - // would require an explicit supervisor/container handshake. + // For supervised external processes, this can still leave a wider + // spawn-to-bind window because the supervisor starts before its + // child binds published ports. Closing that gap would require an + // explicit supervisor/child handshake. yield* options?.beforeSpawn?.(def.name) ?? Effect.void; // Spawn the process @@ -632,29 +635,30 @@ export class Orchestrator extends Context.Service< yield* sendEvent(name, { _tag: "ProcessExited", exitCode: 143 }); }); - const restartClosureFor = (name: string): ReadonlyArray => { - const names = new Set([name]); - const visited = new Set(); - const collectDependents = (current: string): boolean => { - if (visited.has(current)) return names.has(current); - visited.add(current); - let hasActiveDependent = false; - for (const dependent of graph.dependentsOf(current)) { - const dependentService = services.get(dependent.name); - const dependentIsActive = - FiberMap.hasUnsafe(fibers, dependent.name) || - (dependentService !== undefined && - SubscriptionRef.getUnsafe(dependentService.state).desired === "running"); - const descendantIsActive = collectDependents(dependent.name); - if (dependentIsActive || descendantIsActive) { - names.add(dependent.name); - hasActiveDependent = true; - } - } - return hasActiveDependent; - }; - collectDependents(name); - return graph.startOrder.filter((def) => names.has(def.name)); + const restartClosure = (name: string): ReadonlyArray => { + const activeServices = new Set( + graph.startOrder + .filter((def) => { + const service = services.get(def.name); + return ( + FiberMap.hasUnsafe(fibers, def.name) || + (service !== undefined && + SubscriptionRef.getUnsafe(service.state).desired === "running") + ); + }) + .map((def) => def.name), + ); + const closure = new Set( + restartClosureFor( + { + order: graph.startOrder.map((def) => def.name), + dependentsOf: (service) => graph.dependentsOf(service).map((def) => def.name), + }, + name, + activeServices, + ), + ); + return graph.startOrder.filter((def) => closure.has(def.name)); }; const waitReadySingle = (def: ServiceDef): Effect.Effect => @@ -861,7 +865,7 @@ export class Orchestrator extends Context.Service< if (lookupDef(name) === undefined) { return yield* Effect.fail(new ServiceNotFoundError({ name })); } - const affected = restartClosureFor(name); + const affected = restartClosure(name); for (const affectedDef of [...affected].reverse()) { yield* setDesired(affectedDef.name, "stopped"); yield* sendEvent(affectedDef.name, { _tag: "StopRequested" }); @@ -876,7 +880,7 @@ export class Orchestrator extends Context.Service< if (def === undefined) { return yield* Effect.fail(new ServiceNotFoundError({ name })); } - const affected = restartClosureFor(name); + const affected = restartClosure(name); for (const affectedDef of [...affected].reverse()) { yield* stopForRestart(affectedDef.name); diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index 954ce355b6..520375e86a 100644 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ b/packages/process-compose/src/Orchestrator.unit.test.ts @@ -48,7 +48,6 @@ function mockLogBuffer() { line: entry.line, })); }), - truncate: () => Effect.void, }), get entries() { return entries; @@ -455,11 +454,17 @@ describe("Orchestrator", () => { it.live("supervised services spawn the supervisor runtime", () => { const { layer, proc } = setupOrchestrator([ - svc("postgres", { - command: "docker", - args: ["run", "--rm", "postgres"], + svc("database", { + command: "container-runtime", + args: ["run", "database"], supervision: { - orphanCleanup: [{ _tag: "DockerRemove", containerName: "supabase-postgres-test" }], + orphanCleanup: [ + { + _tag: "RunCommand", + executable: "container-runtime", + args: ["remove", "database-test"], + }, + ], }, }), ]); @@ -476,11 +481,17 @@ describe("Orchestrator", () => { it.live("stopping a supervisor during its spawn handshake cleans it up", () => { const { layer, proc } = setupOrchestrator( [ - svc("postgres", { - command: "docker", - args: ["run", "--rm", "postgres"], + svc("database", { + command: "container-runtime", + args: ["run", "database"], supervision: { - orphanCleanup: [{ _tag: "DockerRemove", containerName: "supabase-postgres-test" }], + orphanCleanup: [ + { + _tag: "RunCommand", + executable: "container-runtime", + args: ["remove", "database-test"], + }, + ], }, }), ], @@ -488,10 +499,10 @@ describe("Orchestrator", () => { ); return Effect.gen(function* () { const orc = yield* Orchestrator; - yield* orc.startService("postgres", { beforeSpawn: () => Effect.void }); + yield* orc.startService("database", { beforeSpawn: () => Effect.void }); yield* proc.waitForSpawnCount(1); - yield* orc.stopService("postgres"); + yield* orc.stopService("database"); yield* proc.waitForKillCount(1); expect(proc.killed[0]?.command).toBe(process.execPath); diff --git a/packages/process-compose/src/RestartClosure.ts b/packages/process-compose/src/RestartClosure.ts new file mode 100644 index 0000000000..2871e38fed --- /dev/null +++ b/packages/process-compose/src/RestartClosure.ts @@ -0,0 +1,37 @@ +export interface RestartClosureGraph { + readonly order: ReadonlyArray; + readonly dependentsOf: (name: string) => ReadonlyArray; +} + +/** + * Returns the requested service and every connector on a path to an active dependent. + * The result preserves dependency start order so callers can stop it in reverse safely. + */ +export const restartClosureFor = ( + graph: RestartClosureGraph, + name: string, + activeServices: ReadonlySet, +): ReadonlyArray => { + const closure = new Set([name]); + const visited = new Set(); + + const collectDependents = (current: string): boolean => { + if (visited.has(current)) { + return closure.has(current); + } + visited.add(current); + + let connectsToActiveDependent = false; + for (const dependent of graph.dependentsOf(current)) { + const descendantConnectsToActive = collectDependents(dependent); + if (activeServices.has(dependent) || descendantConnectsToActive) { + closure.add(dependent); + connectsToActiveDependent = true; + } + } + return connectsToActiveDependent; + }; + + collectDependents(name); + return graph.order.filter((service) => closure.has(service)); +}; diff --git a/packages/process-compose/src/RestartClosure.unit.test.ts b/packages/process-compose/src/RestartClosure.unit.test.ts new file mode 100644 index 0000000000..306d926c9b --- /dev/null +++ b/packages/process-compose/src/RestartClosure.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { restartClosureFor, type RestartClosureGraph } from "./RestartClosure.ts"; + +const graph = ( + order: ReadonlyArray, + dependents: Readonly>>, +): RestartClosureGraph => ({ + order, + dependentsOf: (name) => dependents[name] ?? [], +}); + +describe("restartClosureFor", () => { + const cases: ReadonlyArray<{ + readonly name: string; + readonly graph: RestartClosureGraph; + readonly root: string; + readonly active: ReadonlyArray; + readonly expected: ReadonlyArray; + }> = [ + { + name: "keeps only the requested service when no dependent is active", + graph: graph(["db", "api", "web"], { db: ["api"], api: ["web"] }), + root: "db", + active: [], + expected: ["db"], + }, + { + name: "includes active direct dependents in dependency order", + graph: graph(["db", "api", "worker"], { db: ["api", "worker"] }), + root: "db", + active: ["worker", "api"], + expected: ["db", "api", "worker"], + }, + { + name: "preserves inactive connectors leading to an active descendant", + graph: graph(["db", "gateway", "api", "web"], { + db: ["gateway"], + gateway: ["api"], + api: ["web"], + }), + root: "db", + active: ["web"], + expected: ["db", "gateway", "api", "web"], + }, + { + name: "continues through active dependents to include active descendants", + graph: graph(["db", "api", "web"], { db: ["api"], api: ["web"] }), + root: "db", + active: ["api", "web"], + expected: ["db", "api", "web"], + }, + { + name: "handles shared connectors without duplicating diamond nodes", + graph: graph(["db", "api", "worker", "web"], { + db: ["api", "worker"], + api: ["web"], + worker: ["web"], + }), + root: "db", + active: ["web"], + expected: ["db", "api", "worker", "web"], + }, + ]; + + for (const testCase of cases) { + it(testCase.name, () => { + expect(restartClosureFor(testCase.graph, testCase.root, new Set(testCase.active))).toEqual( + testCase.expected, + ); + }); + } +}); diff --git a/packages/process-compose/src/ServiceDef.ts b/packages/process-compose/src/ServiceDef.ts index d015fcdae1..6cdc03150b 100644 --- a/packages/process-compose/src/ServiceDef.ts +++ b/packages/process-compose/src/ServiceDef.ts @@ -55,8 +55,10 @@ export interface LifecycleHook { export type ExternalCleanupAction = | { - readonly _tag: "DockerRemove"; - readonly containerName: string; + readonly _tag: "RunCommand"; + readonly executable: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; } | { readonly _tag: "RemovePath"; diff --git a/packages/process-compose/src/ServiceTransition.ts b/packages/process-compose/src/ServiceTransition.ts index 94c28528ed..ea9212a754 100644 --- a/packages/process-compose/src/ServiceTransition.ts +++ b/packages/process-compose/src/ServiceTransition.ts @@ -28,52 +28,43 @@ export type ServiceEvent = | { readonly _tag: "HookFailed"; readonly error: string }; // --------------------------------------------------------------------------- -// Transition table — set of (fromStatus, eventTag) pairs that are legal +// Transition table — every event must classify its legal source statuses // --------------------------------------------------------------------------- -const allowed = new Set<`${ServiceStatus}:${ServiceEvent["_tag"]}`>([ - "Pending:DependenciesSatisfied", - "Pending:DependencyFailed", - "Pending:SpawnFailed", - "Pending:StopRequested", - "Starting:ProcessSpawned", - "Starting:SpawnFailed", - "Starting:StopRequested", - "Starting:HookFailed", - "Running:HealthCheckPassed", - "Running:HealthCheckFailed", - "Running:ProcessExited", - "Running:StopRequested", - "Healthy:HealthCheckPassed", - "Healthy:HealthCheckFailed", - "Healthy:ProcessExited", - "Healthy:StopRequested", - "Unhealthy:HealthCheckPassed", - "Unhealthy:ProcessExited", - "Unhealthy:ProcessTerminated", - "Unhealthy:StopRequested", - "Stopping:ProcessExited", - "Stopped:RestartTriggered", - "Failed:RestartTriggered", - "Failed:ProcessExited", - "Failed:StopRequested", - "Unhealthy:RestartTriggered", - "Unhealthy:UnhealthyRestartExhausted", - "Restarting:StopRequested", - "Restarting:SpawnFailed", - "Restarting:BackoffElapsed", - "Running:HookFailed", - "Healthy:HookFailed", - "Unhealthy:HookFailed", -]); +type TransitionTable = { + readonly [Tag in ServiceEvent["_tag"]]: ReadonlySet; +}; + +const transitions: TransitionTable = { + DependenciesSatisfied: new Set(["Pending"]), + DependencyFailed: new Set(["Pending"]), + SpawnFailed: new Set(["Pending", "Starting", "Restarting"]), + ProcessSpawned: new Set(["Starting"]), + HealthCheckPassed: new Set(["Running", "Healthy", "Unhealthy"]), + HealthCheckFailed: new Set(["Running", "Healthy"]), + ProcessTerminated: new Set(["Unhealthy"]), + UnhealthyRestartExhausted: new Set(["Unhealthy"]), + ProcessExited: new Set(["Running", "Healthy", "Unhealthy", "Stopping", "Failed"]), + StopRequested: new Set([ + "Pending", + "Starting", + "Running", + "Healthy", + "Unhealthy", + "Restarting", + "Failed", + ]), + RestartTriggered: new Set(["Stopped", "Failed", "Unhealthy"]), + BackoffElapsed: new Set(["Restarting"]), + HookFailed: new Set(["Starting", "Running", "Healthy", "Unhealthy"]), +}; // --------------------------------------------------------------------------- // applyEvent — pure function, returns new ServiceState or null if invalid // --------------------------------------------------------------------------- export const applyEvent = (state: ServiceState, event: ServiceEvent): ServiceState | null => { - const key = `${state.status}:${event._tag}` as const; - if (!allowed.has(key)) return null; + if (!transitions[event._tag].has(state.status)) return null; switch (event._tag) { case "DependenciesSatisfied": diff --git a/packages/process-compose/src/ServiceTransition.unit.test.ts b/packages/process-compose/src/ServiceTransition.unit.test.ts index 58b41524f6..c34b260ce2 100644 --- a/packages/process-compose/src/ServiceTransition.unit.test.ts +++ b/packages/process-compose/src/ServiceTransition.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyEvent } from "./ServiceTransition.ts"; -import { ServiceState, initial } from "./ServiceState.ts"; +import { applyEvent, type ServiceEvent } from "./ServiceTransition.ts"; +import { ServiceState, initial, type ServiceStatus } from "./ServiceState.ts"; const make = ( name: string, @@ -19,6 +19,73 @@ const make = ( }); describe("ServiceTransition", () => { + it("classifies every event against every service status", () => { + const statuses: ReadonlyArray = [ + "Pending", + "Starting", + "Running", + "Healthy", + "Unhealthy", + "Stopping", + "Stopped", + "Failed", + "Restarting", + ]; + const events: { readonly [Tag in ServiceEvent["_tag"]]: Extract } = + { + DependenciesSatisfied: { _tag: "DependenciesSatisfied" }, + DependencyFailed: { _tag: "DependencyFailed", error: "dependency failed" }, + SpawnFailed: { _tag: "SpawnFailed", error: "spawn failed" }, + ProcessSpawned: { _tag: "ProcessSpawned", pid: 1234, startedAt: 1000 }, + HealthCheckPassed: { _tag: "HealthCheckPassed" }, + HealthCheckFailed: { _tag: "HealthCheckFailed" }, + ProcessTerminated: { _tag: "ProcessTerminated" }, + UnhealthyRestartExhausted: { + _tag: "UnhealthyRestartExhausted", + error: "restart exhausted", + }, + ProcessExited: { _tag: "ProcessExited", exitCode: 1 }, + StopRequested: { _tag: "StopRequested" }, + RestartTriggered: { _tag: "RestartTriggered", restartCount: 1 }, + BackoffElapsed: { _tag: "BackoffElapsed" }, + HookFailed: { _tag: "HookFailed", error: "hook failed" }, + }; + const legalStatuses: { + readonly [Tag in ServiceEvent["_tag"]]: ReadonlyArray; + } = { + DependenciesSatisfied: ["Pending"], + DependencyFailed: ["Pending"], + SpawnFailed: ["Pending", "Starting", "Restarting"], + ProcessSpawned: ["Starting"], + HealthCheckPassed: ["Running", "Healthy", "Unhealthy"], + HealthCheckFailed: ["Running", "Healthy"], + ProcessTerminated: ["Unhealthy"], + UnhealthyRestartExhausted: ["Unhealthy"], + ProcessExited: ["Running", "Healthy", "Unhealthy", "Stopping", "Failed"], + StopRequested: [ + "Pending", + "Starting", + "Running", + "Healthy", + "Unhealthy", + "Failed", + "Restarting", + ], + RestartTriggered: ["Unhealthy", "Stopped", "Failed"], + BackoffElapsed: ["Restarting"], + HookFailed: ["Starting", "Running", "Healthy", "Unhealthy"], + }; + + for (const event of Object.values(events)) { + for (const status of statuses) { + const state = make("service", { status, pid: 1234 }); + expect(applyEvent(state, event) !== null, `${status} + ${event._tag}`).toBe( + legalStatuses[event._tag].includes(status), + ); + } + } + }); + describe("valid transitions", () => { it("Pending + DependenciesSatisfied → Starting", () => { const state = make("db"); diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts index 77b7b61b5d..4cdd6321eb 100644 --- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts +++ b/packages/process-compose/src/SupervisorRuntime.unit.test.ts @@ -2,10 +2,38 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, test } from "vitest"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, test } from "vitest"; +import { makeSupervisorRuntimeEnv, withoutSupervisorRuntimeEnv } from "./supervisor-protocol.ts"; const supervisorRuntimePath = fileURLToPath(new URL("./supervisor-runtime.ts", import.meta.url)); +const supervisorProtocolPath = fileURLToPath(new URL("./supervisor-protocol.ts", import.meta.url)); + +type SupervisorEntry = "source path" | "compiled self-dispatch"; + +const spawnSupervisor = (entry: SupervisorEntry, encodedConfig: string) => { + if (entry === "source path") { + return spawn(process.execPath, [supervisorRuntimePath, encodedConfig], { + stdio: ["pipe", "ignore", "ignore"], + }); + } + + const runtimeUrl = pathToFileURL(supervisorRuntimePath).href; + const protocolUrl = pathToFileURL(supervisorProtocolPath).href; + const dispatch = [ + `import { runSupervisorRuntimeFromEnv } from ${JSON.stringify(runtimeUrl)};`, + `import { isSupervisorRuntimeRequested } from ${JSON.stringify(protocolUrl)};`, + `if (!isSupervisorRuntimeRequested()) throw new Error("supervisor dispatch not requested");`, + `runSupervisorRuntimeFromEnv();`, + ].join("\n"); + return spawn(process.execPath, ["--eval", dispatch], { + env: makeSupervisorRuntimeEnv(encodedConfig, { + ...process.env, + PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", + }), + stdio: ["pipe", "ignore", "ignore"], + }); +}; const waitFor = async ( predicate: () => boolean, @@ -39,12 +67,14 @@ const isPidAlive = (pid: number): boolean => { }; describe("supervisor-runtime", () => { - test( - "kills the child tree and runs orphan cleanup when parent stdin closes", + test.each(["source path", "compiled self-dispatch"])( + "%s kills the child tree and runs validated orphan cleanup when parent stdin closes", { timeout: 15_000 }, - async () => { + async (entry) => { const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-")); const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupMarker = path.join(tempDir, "cleanup-command-ran"); + const cleanupEnvironmentMarker = path.join(tempDir, "cleanup-environment.json"); const childPidFile = path.join(tempDir, "child.pid"); const grandchildPidFile = path.join(tempDir, "grandchild.pid"); const readyFile = path.join(tempDir, "ready"); @@ -72,13 +102,32 @@ describe("supervisor-runtime", () => { args: [childScriptPath], shutdownSignal: "SIGTERM", shutdownTimeoutMs: 100, - cleanup: [{ _tag: "RemovePath", path: cleanupDir, recursive: true }], + cleanup: [ + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + [ + `const { writeFileSync } = require("node:fs");`, + `writeFileSync(process.argv[1], process.argv[2]);`, + `writeFileSync(process.argv[3], JSON.stringify({`, + ` run: process.env.PROCESS_COMPOSE_RUN_SUPERVISOR,`, + ` config: process.env.PROCESS_COMPOSE_SUPERVISOR_CONFIG,`, + ` dispatch: process.env.PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH,`, + `}));`, + ].join("\n"), + cleanupMarker, + "literal; $(not-run) & value", + cleanupEnvironmentMarker, + ], + }, + ], }), ).toString("base64url"); - const supervisor = spawn(process.execPath, [supervisorRuntimePath, encodedConfig], { - stdio: ["pipe", "ignore", "ignore"], - }); + const supervisor = spawnSupervisor(entry, encodedConfig); try { await waitFor(() => existsSync(readyFile)); @@ -90,6 +139,9 @@ describe("supervisor-runtime", () => { await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); await waitFor(() => !existsSync(cleanupDir), { timeoutMs: 10_000 }); + await waitFor(() => existsSync(cleanupMarker), { timeoutMs: 10_000 }); + expect(readFileSync(cleanupMarker, "utf8")).toBe("literal; $(not-run) & value"); + expect(JSON.parse(readFileSync(cleanupEnvironmentMarker, "utf8"))).toEqual({}); await waitFor(() => !isPidAlive(childPid), { timeoutMs: 10_000 }); await waitFor(() => !isPidAlive(grandchildPid), { timeoutMs: 10_000 }); } finally { @@ -99,6 +151,142 @@ describe("supervisor-runtime", () => { }, ); + test.each([ + [ + "non-string command argument", + { _tag: "RunCommand", executable: process.execPath, args: [42] }, + ], + ["empty executable", { _tag: "RunCommand", executable: "", args: [] }], + [ + "non-positive timeout", + { _tag: "RunCommand", executable: process.execPath, args: [], timeoutMs: 0 }, + ], + ["invalid path option", { _tag: "RemovePath", path: "/tmp/example", recursive: "yes" }], + ])("rejects a malformed cleanup contract with %s before spawning", async (_name, cleanup) => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-invalid-")); + const childMarker = path.join(tempDir, "child-started"); + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(childMarker)}, "started")`], + cleanup: [cleanup], + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); + + try { + await waitFor(() => supervisor.exitCode != null); + expect(supervisor.exitCode).not.toBe(0); + expect(existsSync(childMarker)).toBe(false); + } finally { + supervisor.kill("SIGKILL"); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("removes supervisor protocol variables from the managed child environment", () => { + const childEnv = withoutSupervisorRuntimeEnv({ + KEEP_ME: "value", + PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", + PROCESS_COMPOSE_RUN_SUPERVISOR: "1", + PROCESS_COMPOSE_SUPERVISOR_CONFIG: "encoded", + }); + + expect(childEnv).toEqual({ KEEP_ME: "value" }); + }); + + test("bounds a cleanup command tree by its timeout and continues remaining cleanup", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupWorkerPidFile = path.join(tempDir, "cleanup-worker.pid"); + const childScriptPath = path.join(tempDir, "child.mjs"); + mkdirSync(cleanupDir); + writeFileSync(childScriptPath, "process.exit(0);\n"); + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: [childScriptPath], + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + [ + `const { spawn } = require("node:child_process");`, + `const { writeFileSync } = require("node:fs");`, + `const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(cleanupWorkerPidFile)}, String(worker.pid));`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ], + timeoutMs: 100, + }, + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + ], + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); + + try { + await waitFor(() => supervisor.exitCode != null); + expect(supervisor.exitCode).toBe(0); + expect(existsSync(cleanupDir)).toBe(false); + const cleanupWorkerPid = Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10); + expect(Number.isSafeInteger(cleanupWorkerPid)).toBe(true); + await waitFor(() => !isPidAlive(cleanupWorkerPid)); + } finally { + supervisor.kill("SIGKILL"); + if (existsSync(cleanupWorkerPidFile)) { + try { + process.kill(Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10), "SIGKILL"); + } catch {} + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("bounds a cleanup command when no timeout is configured", { timeout: 12_000 }, async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupPidFile = path.join(tempDir, "cleanup.pid"); + const childScriptPath = path.join(tempDir, "child.mjs"); + mkdirSync(cleanupDir); + writeFileSync(childScriptPath, "process.exit(0);\n"); + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: [childScriptPath], + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(cleanupPidFile)}, String(process.pid)); setInterval(() => {}, 1000)`, + ], + }, + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + ], + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); + + try { + await waitFor(() => supervisor.exitCode != null, { timeoutMs: 8_000 }); + expect(supervisor.exitCode).toBe(0); + expect(existsSync(cleanupDir)).toBe(false); + } finally { + supervisor.kill("SIGKILL"); + if (existsSync(cleanupPidFile)) { + try { + process.kill(Number.parseInt(readFileSync(cleanupPidFile, "utf8"), 10), "SIGKILL"); + } catch {} + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test( "runs orphan cleanup when the configured owner pid is already gone", { timeout: 15_000 }, diff --git a/packages/process-compose/src/errors.ts b/packages/process-compose/src/errors.ts index 44bde7ff2b..65dc4ea66f 100644 --- a/packages/process-compose/src/errors.ts +++ b/packages/process-compose/src/errors.ts @@ -18,10 +18,6 @@ export class SpawnError extends Data.TaggedError("SpawnError")<{ readonly cause: unknown; }> {} -export class ShutdownTimeoutError extends Data.TaggedError("ShutdownTimeoutError")<{ - readonly service: string; -}> {} - export class ServiceReadyError extends Data.TaggedError("ServiceReadyError")<{ readonly name: string; readonly reason: string; diff --git a/packages/process-compose/src/errors.unit.test.ts b/packages/process-compose/src/errors.unit.test.ts index b2c2920595..073e5279f0 100644 --- a/packages/process-compose/src/errors.unit.test.ts +++ b/packages/process-compose/src/errors.unit.test.ts @@ -4,7 +4,6 @@ import { MissingDependencyError, ServiceNotFoundError, SpawnError, - ShutdownTimeoutError, } from "./errors.ts"; describe("errors", () => { @@ -34,10 +33,4 @@ describe("errors", () => { expect(err.service).toBe("postgres"); expect(err.cause).toBe(cause); }); - - it("ShutdownTimeoutError has correct tag and data", () => { - const err = new ShutdownTimeoutError({ service: "postgres" }); - expect(err._tag).toBe("ShutdownTimeoutError"); - expect(err.service).toBe("postgres"); - }); }); diff --git a/packages/process-compose/src/index.ts b/packages/process-compose/src/index.ts index 609f5db891..421763613f 100644 --- a/packages/process-compose/src/index.ts +++ b/packages/process-compose/src/index.ts @@ -25,7 +25,6 @@ export { ServiceNotFoundError, ServiceReadyError, SpawnError, - ShutdownTimeoutError, } from "./errors.ts"; export type { LogEntry } from "./LogBuffer.ts"; diff --git a/packages/process-compose/src/supervisor-runtime.ts b/packages/process-compose/src/supervisor-runtime.ts index 302961ffdf..f3114183f5 100644 --- a/packages/process-compose/src/supervisor-runtime.ts +++ b/packages/process-compose/src/supervisor-runtime.ts @@ -9,6 +9,7 @@ import { } from "./supervisor-protocol.ts"; type RemovePathAction = Extract; +type RunCommandAction = Extract; interface SupervisorRuntimeConfig { readonly command: string; @@ -24,6 +25,8 @@ interface ChildExit { readonly signal: NodeJS.Signals | null; } +const DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS = 5_000; + const isMain = (() => { if (process.argv[1] == null) { return false; @@ -104,16 +107,35 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined => } const tag = getField(value, "_tag"); - if (tag === "DockerRemove") { - const containerName = getField(value, "containerName"); - return typeof containerName === "string" ? { _tag: tag, containerName } : undefined; + if (tag === "RunCommand") { + const executable = getField(value, "executable"); + const args = stringArrayFrom(getField(value, "args")); + const timeoutMs = getField(value, "timeoutMs"); + if ( + typeof executable !== "string" || + executable.length === 0 || + args === undefined || + (timeoutMs !== undefined && + (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) + ) { + return undefined; + } + return { + _tag: tag, + executable, + args, + timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined, + }; } if (tag === "RemovePath") { const path = getField(value, "path"); const recursive = getField(value, "recursive"); const force = getField(value, "force"); - return typeof path === "string" + return typeof path === "string" && + path.length > 0 && + (recursive === undefined || typeof recursive === "boolean") && + (force === undefined || typeof force === "boolean") ? { _tag: tag, path, @@ -148,6 +170,11 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC const ownerPid = getField(value, "ownerPid"); const shutdownTimeoutMs = getField(value, "shutdownTimeoutMs"); + const cleanupValue = getField(value, "cleanup"); + const cleanup = cleanupValue === undefined ? undefined : cleanupActionsFrom(cleanupValue); + if (cleanupValue !== undefined && cleanup === undefined) { + throw new Error("Invalid supervisor cleanup"); + } return { command, @@ -155,7 +182,7 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC ownerPid: typeof ownerPid === "number" ? ownerPid : undefined, shutdownSignal: signalFrom(getField(value, "shutdownSignal")), shutdownTimeoutMs: typeof shutdownTimeoutMs === "number" ? shutdownTimeoutMs : undefined, - cleanup: cleanupActionsFrom(getField(value, "cleanup")), + cleanup, }; }; @@ -207,14 +234,10 @@ export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { } }; - const killChildTree = (signal: ChildProcess.Signal): void => { - if (child.pid == null) { - return; - } - + const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => { if (isWindows) { try { - execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", timeout: 5_000, }); @@ -224,16 +247,48 @@ export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { } try { - process.kill(-child.pid, signal); + process.kill(-pid, signal); return; } catch {} try { - process.kill(child.pid, signal); + process.kill(pid, signal); } catch {} }; - const runCleanup = () => { + const killChildTree = (signal: ChildProcess.Signal): void => { + if (child.pid != null) { + killProcessTree(child.pid, signal); + } + }; + + const runCleanupCommand = (action: RunCommandAction): Promise => + new Promise((resolve) => { + const cleanupChild = spawn(action.executable, action.args, { + detached: !isWindows, + env: childEnv, + stdio: "ignore", + }); + let timeoutId: ReturnType | undefined; + + const finish = () => { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + resolve(); + }; + + cleanupChild.once("error", finish); + cleanupChild.once("exit", finish); + timeoutId = setTimeout(() => { + if (cleanupChild.pid != null) { + killProcessTree(cleanupChild.pid, "SIGKILL"); + } + finish(); + }, action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS); + }); + + const runCleanup = async (): Promise => { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const removePathWithRetry = async (action: RemovePathAction): Promise => { for (let attempt = 0; attempt < 20; attempt++) { @@ -249,20 +304,26 @@ export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { } }; - return Promise.all( - (config.cleanup ?? []).map(async (action) => { + const runCommands = async () => { + for (const action of config.cleanup ?? []) { + if (action._tag !== "RunCommand") { + continue; + } + try { - if (action._tag === "DockerRemove") { - execFileSync("docker", ["rm", "-f", action.containerName], { - stdio: "ignore", - timeout: 5_000, - }); - } else if (action._tag === "RemovePath") { - await removePathWithRetry(action); - } + await runCleanupCommand(action); } catch {} - }), - ).then(() => undefined); + } + }; + + // Commands serialize so their worst-case timeouts add together. Each runs in its own Unix + // process group (or uses taskkill on Windows), allowing a timeout to terminate its whole tree. + await Promise.all([ + runCommands(), + ...(config.cleanup ?? []).map((action) => + action._tag === "RemovePath" ? removePathWithRetry(action) : Promise.resolve(), + ), + ]); }; const shutdown = async (signal: ChildProcess.Signal): Promise => { diff --git a/packages/stack/README.md b/packages/stack/README.md index 85d2db88e1..0294bc01e8 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -130,6 +130,40 @@ const stack = await createStack({ }); ``` +### Edge Functions + +The stack accepts an explicit, fully resolved Functions bundle. Paths must be absolute and the +caller owns project-file discovery, environment-file parsing, and manifest interpretation: + +```typescript +const projectDir = "/absolute/project"; +const stack = await createStack({ + projectDir, + functions: { + env: { SHARED_VALUE: "available to every function" }, + functions: [ + { + name: "hello", + verifyJWT: true, + entrypointPath: "/absolute/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_VALUE: "available only to hello" }, + }, + ], + }, +}); +``` + +Every referenced path must be contained by `projectDir` so the same bundle works when Edge Runtime +runs in Docker and the project directory is bind-mounted into the container. + +Per-function environment values override shared values. Stack-owned runtime URLs and credentials +take final precedence. To update the active bundle, call +`reloadFunctions({ functions: nextBundle })`; `reloadFunctions()` preserves and reapplies the most +recent bundle. `reloadEdgeRuntime()` follows the same preservation rule when its optional +`functions` field is omitted. + ## Docker Mode Set `mode: "docker"` to force all services to run in Docker containers, bypassing native binary resolution: @@ -198,17 +232,21 @@ service and its projected status. ### Readiness ```typescript -await stack.ready(); // Wait for all services -await stack.ready({ timeout: 30_000 }); // With timeout (ms) -await stack.serviceReady("postgres"); // Wait for one service -await stack.serviceReady("auth", { timeout: 10_000 }); +await stack.ready(); // Inherit the stack's finite three-minute default +await stack.ready({ mode: "finite", timeoutMs: 30_000 }); +await stack.ready({ mode: "infinite" }); // Explicit debugging override +await stack.serviceReady("postgres"); +await stack.serviceReady("auth", { mode: "finite", timeoutMs: 10_000 }); ``` In eager mode, `start()` blocks until every enabled service is ready. In lazy mode it waits only for direct listeners and services activated so far. Unrequested lazy services report `Dormant`. Calling `serviceReady()` for a dormant lazy service fails immediately; activate it through the proxy or call `startService()` first. Foreground -and detached stacks use the same readiness rules. +and detached stacks use the same readiness rules. The configured policy also applies to service +start, restart, activation, and reload operations; a call-specific option overrides it. A finite +deadline fails with `STACK_READINESS_TIMEOUT` and disposes the local runtime, so the handle cannot +be used to relaunch processes afterward. ### Status @@ -276,8 +314,8 @@ await prefetch({ versions: { postgres: "17.4.1.045" } }); ## Service Versions Default versions are used when no per-service `version` field is specified. The authoritative, -exhaustive values are exported as `DEFAULT_VERSIONS` and live in -[`src/versions.ts`](./src/versions.ts); they are intentionally not copied into this README. +exhaustive values live in [`src/ServiceCatalog.ts`](./src/ServiceCatalog.ts) and are exported as +the derived `DEFAULT_VERSIONS` manifest; they are intentionally not copied into this README. Override versions per service: @@ -307,15 +345,16 @@ try { } ``` -| Code | Description | -| ------------------- | -------------------------------------------- | -| `SERVICE_NOT_FOUND` | Referenced a service that doesn't exist | -| `SERVICE_NOT_READY` | Service failed to become healthy | -| `BUILD_ERROR` | Failed to build the service dependency graph | -| `BINARY_NOT_FOUND` | No binary available for the current platform | -| `DOWNLOAD_ERROR` | Binary download failed | -| `PORT_CONFLICT` | Requested port is already in use | -| `PORT_ALLOCATION` | Failed to allocate a free port | +| Code | Description | +| ------------------------- | -------------------------------------------- | +| `SERVICE_NOT_FOUND` | Referenced a service that doesn't exist | +| `SERVICE_NOT_READY` | Service failed to become healthy | +| `STACK_READINESS_TIMEOUT` | Stack readiness exceeded its finite deadline | +| `BUILD_ERROR` | Failed to build the service dependency graph | +| `BINARY_NOT_FOUND` | No binary available for the current platform | +| `DOWNLOAD_ERROR` | Binary download failed | +| `PORT_CONFLICT` | Requested port is already in use | +| `PORT_ALLOCATION` | Failed to allocate a free port | ## Examples diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 042f24cf7f..54558ffa95 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -11,16 +11,18 @@ The package exposes two levels of Interface: - `@supabase/stack` selects `bun.ts` or `node.ts` through export conditions and exposes the Promise-oriented `createStack()` / `StackHandle` Interface plus prefetch helpers. -- `@supabase/stack/effect` exposes Effect Interfaces and layer factories used by the CLI and - advanced callers. +- `@supabase/stack/effect` selects a runtime Adapter through the same export conditions and exposes + Effect Interfaces plus platform-bound layer factories used by the CLI and advanced callers. +- `@supabase/stack/testing` exposes only the service tags needed to replace daemon transport in + consumer tests. Runtime implementation tags do not leak through the root or Effect barrels. -The root runtime Adapters provide Effect filesystem, path, child-process, HTTP-server, and Unix -socket HTTP implementations. `createStack.ts` remains platform-agnostic and receives a -`PlatformFactory`. +Internal runtime Adapters provide Effect filesystem, path, child-process, HTTP-server, and Unix +socket HTTP implementations. `createStack.ts` and the layer factories remain platform-agnostic; +the conditional root and Effect entries bind them to their selected runtime. ```mermaid flowchart LR - Input["StackConfig"] --> Resolve["resolveConfig"] + Input["StackConfig"] --> Resolve["StackConfigResolver"] Resolve --> Layer["foregroundLayer"] Layer --> Prepare["StackPreparation"] Prepare --> Builder["StackBuilder"] @@ -37,10 +39,11 @@ can use the same lifecycle calls against an in-process stack or a detached daemo ## Configuration and roots `StackConfig` is an in-memory library input, not the project configuration-file schema. Its -top-level fields choose runtime mode, startup mode, cache/runtime roots, API keys, JWT secret, -functions options, and per-service configuration. `false` disables an optional service. +top-level fields choose runtime mode, startup mode, cache/runtime roots, API keys, JWT secret, a +resolved Edge Functions bundle, and per-service configuration. `false` disables an optional +service. -`resolveConfig()`: +`StackConfigResolver.resolveConfig()`: 1. chooses cache, durable stack, runtime, and project roots; 2. allocates every required port through one port allocator; @@ -48,6 +51,14 @@ functions options, and per-service configuration. `false` disables an optional s 4. applies per-service defaults and current `DEFAULT_VERSIONS`; 5. records auto-managed paths for scoped cleanup. +Readiness policy is part of the resolved configuration. The package default is a finite three-minute +deadline; callers can choose a different finite deadline or explicit infinite waiting. Per-call +`ReadyOptions` take precedence over the stack policy, while `inherit` delegates to the stack +policy. The local Implementation applies this resolver to startup, service activation, restart, +reload, and explicit readiness waits. A finite deadline fails with `StackReadinessError` and runs +the same scoped cleanup used by disposal. Promise and remote Adapters pass `ReadyOptions` through +to that Implementation instead of layering a second timeout rule around it. + The current zero-config stack enables PostgreSQL, PostgREST, Auth, and Edge Runtime. Realtime, Storage, imgproxy, Mailpit, Postgres Meta, Studio, Analytics, Vector, and Supavisor are enabled only when their corresponding configuration object is present. In `native` mode, Edge Runtime is also @@ -65,7 +76,8 @@ disabled by omission because the automatic artifact policy currently classifies Preparation is separate from topology construction: -- `ServiceArtifacts.ts` records native release providers and Docker image candidates. +- `ServiceCatalog.ts` is the exhaustive source for service identity, default version, runtime + support, artifact providers, activation policy, and allocated port fields. - `BinaryResolver` detects the platform, downloads and verifies archives, restores executable permissions, and publishes complete cache entries atomically. - `StackPreparation` resolves all enabled public services, emits download/pull progress, and @@ -76,13 +88,17 @@ Native cache identity includes service, provider, version, and asset name. `.com last, so an incomplete download is never treated as reusable. Supabase-owned Docker images are tried through ECR, Docker Hub, then GHCR; upstream images use their canonical repository. -`prefetch()` uses the same `StackPreparation` Interface without constructing a lifecycle runtime. +`ServiceResolution` belongs to this preparation domain. `prefetch()` uses the same +`StackPreparation` Interface and its binary-to-Docker fallback without constructing a lifecycle +runtime. ## Service coverage and topology `StackBuilder.build(config, prepared)` is the explicit owner of cross-service topology. Individual factories under `src/services/` own executable arguments, environment, mounts, health checks, and -per-process cleanup. The builder owns which definitions exist and how they depend on one another. +per-process cleanup. Docker factories also own their host-network and port-mapping arguments. The +builder owns which definitions exist and how they depend on one another, including the choice +between `postgres-init (completed)` and `postgres (healthy)` for every database consumer. | Public service | Automatic runtime support | Principal dependency or role | | -------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | @@ -110,7 +126,7 @@ Vector without Analytics, and Studio without Postgres Meta. ## Lifecycle ownership -The current local Implementation is `StackLifecycleCoordinator`. It owns one scoped lifecycle: +The local Implementation is `LocalStack`. Its scoped layer owns one lifecycle: - preparation and its single-flight deferred; - graph construction and the process-compose runtime; @@ -120,23 +136,26 @@ The current local Implementation is `StackLifecycleCoordinator`. It owns one sco - exact cleanup targets and metadata persistence; - disposal of processes, Docker resources, ports, and auto-managed paths. -`Stack.ts` currently provides a thin public Effect Interface over that coordinator. `ApiProxy` -uses the narrower `StackServiceActivator` Interface so an incoming request can activate a lazy -backend without gaining unrelated lifecycle operations. +`Stack.ts` contains only the public Effect Interface and transport schemas. `LocalStack` constructs +the state once and publishes both `Stack` and the narrower `StackServiceActivator` Interface from +the same scoped layer. `ApiProxy` therefore activates a lazy backend without gaining unrelated +lifecycle operations or requiring a second pass-through lifecycle tag. -Before the orchestrator exists, the coordinator publishes synthetic `Pending` and `Downloading` +Before the orchestrator exists, `LocalStack` publishes synthetic `Pending` and `Downloading` states. After construction, it subscribes to raw process-compose state and publishes only public projected states. `StackServiceState` adds `Downloading`, `Initializing`, and `Dormant` to the raw process statuses. `start()` prepares artifacts, creates the runtime once, starts the appropriate services, and waits for their generic process-compose readiness. `stop()` preserves explicit per-service stop intent; -`dispose()` additionally closes the scoped runtime and executes cleanup. Current generic readiness -has no built-in deadline. +`dispose()` additionally closes the scoped runtime and executes cleanup. Stack readiness policy is +enforced around generic process-compose waits, which remain intentionally policy-free and +unbounded. Structural `Equal.equals` comparison suppresses duplicate projected state emissions. ## Eager and lazy activation -`ServiceActivation.ts` is the declarative startup and companion-ownership policy: +`ServiceActivation.ts` evaluates the startup and companion-ownership metadata in +`ServiceCatalog.ts`: - eager: PostgreSQL, Realtime, Mailpit, Studio, and Pooler; - lazy: PostgREST, Auth, Edge Runtime, Storage, imgproxy, Postgres Meta, Analytics, and Vector; @@ -172,15 +191,22 @@ Cleanup targets do not belong to `StackInfo`; they are internal runtime metadata ## Functions runtime configuration and reload -The current `functions.ts` Implementation discovers project configuration and function manifests, -resolves paths and environment values, combines them with stack URLs/keys, and writes -`functions-runtime-config.json` under the Edge Runtime workspace. The Edge Runtime factory mounts -or references that file. +Project discovery is outside the stack boundary. A caller supplies a serializable +`ResolvedFunctionsBundle` containing absolute entrypoint, optional import-map, and static-file +paths plus already-resolved shared and per-function environment values. The import-map path is +explicitly nullable. Per-function environment values override shared values; stack-owned runtime +URLs and credentials take final precedence when the worker is created. + +`LocalStack` keeps the current bundle in runtime-local memory. `reloadFunctions({ functions })` +replaces it, while a reload without `functions` preserves the latest bundle. An Edge Runtime reload +uses that same current bundle unless its body supplies a replacement. The stack combines the +bundle with runtime URLs and credentials, atomically publishes `functions-runtime-config.json` +with owner-only permissions under the Edge Runtime workspace, and removes it on disposal. -`reloadFunctions()` rewrites the file and updates/restarts the Edge Runtime definition. -`reloadEdgeRuntime()` can change runtime settings and optionally functions settings. In detached -mode, `/functions/reload` currently carries `envFile` and `noVerifyJwt` as query parameters, while -`/edge-runtime/reload` accepts a validated JSON body. +Detached stacks deliberately exclude resolved bundles from daemon startup IPC, durable metadata, +live state, logs, URLs, and rendered validation errors. Both `/functions/reload` and +`/edge-runtime/reload` accept validated JSON bodies over the local Unix socket. This keeps resolved +environment values confined to an explicit request body and the ephemeral runtime file. ## Port leases @@ -199,8 +225,10 @@ binding. Supervised Docker services have a wider window because the supervisor s Every Docker definition has ordinary in-process cleanup and supervisor-owned orphan cleanup. `StackBuilder` also returns exact Docker container names for the definitions it constructed. The -coordinator persists these targets for managed daemons and uses them as a force-removal safety net -after graceful stop. Auto-created PostgreSQL, Storage, and runtime paths are also removed. +local Implementation captures these targets before persistence or orchestrator setup, persists +them for managed daemons, and uses them as a force-removal safety net after graceful stop. Launch, +exact cleanup, and candidate cleanup all derive container identity through the same naming +function. Auto-created PostgreSQL, Storage, and runtime paths are also removed. Cleanup is intentionally defensive: @@ -209,7 +237,8 @@ Cleanup is intentionally defensive: external resources; 3. stack disposal force-removes exact known Docker containers; 4. managed `stop` can use persisted cleanup metadata after daemon death; -5. startup failure has candidate cleanup derived from the requested configuration. +5. a failure before the exact build plan exists has candidate cleanup derived from enabled catalog + services; a partial startup failure disposes the exact build-produced plan. These paths overlap by design and must remain idempotent. @@ -220,15 +249,17 @@ These paths overlap by design and must remain idempotent. Detached mode adds: - `daemonLayer()`: forks a runtime-specific daemon entrypoint and returns a `RemoteStack` layer; -- `daemon.ts`: receives the configuration over Node IPC, resolves ports, builds the foreground - daemon layer, claims live state, and waits for HTTP stop or a signal; +- `daemon.ts`: receives configuration excluding the resolved Functions bundle over Node IPC, + resolves ports, builds the foreground daemon layer, claims live state, and waits for HTTP stop or + a signal; - `DaemonServer`: exposes the `Stack` Interface over HTTP/SSE on a Unix-domain socket; - `RemoteStack`: maps that transport back to the same Effect `Stack` Interface; - `StateManager`: atomically persists and discovers durable metadata and live state. The management transport includes health, status, status stream, start/stop, readiness, -per-service lifecycle, logs/history, and Edge Runtime reload routes. It is local Unix-socket -transport, not the public Supabase API proxy. +per-service lifecycle, logs/history, and Edge Runtime reload routes. Readiness waits use validated +`ReadyOptions` JSON bodies and preserve `StackReadinessError` across the transport. It is local +Unix-socket transport, not the public Supabase API proxy. See [detach mode](./detach-mode.md) for paths, process startup, and compiled executable dispatch. @@ -260,12 +291,15 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l ## Runtime entrypoints and exports - `bun.ts` and `node.ts` are root export-condition targets. +- `effect-bun.ts` and `effect-node.ts` are Effect export-condition targets. They bind foreground, + daemon, and Unix-socket layers without exposing raw platform factories or bootstrap paths. - `daemon-bun.ts` is exported as `@supabase/stack/daemon-bun` so the compiled CLI can dispatch to it in-process. -- `daemon-node.ts` is intentionally not a package export. `node.ts` resolves it by file URL and - passes that filesystem path to `daemonLayer`; the package `knip.entry` list preserves this live - file-URL-only entrypoint. -- `effect.ts` is the low-level Effect export used by the CLI. There is no `internals.ts` entrypoint. +- `daemon-node.ts` is intentionally not a package export. The internal Node platform Adapter + resolves it by file URL and passes that filesystem path to `daemonLayer`; the package + `knip.entry` list preserves this live file-URL-only entrypoint. +- `effect.ts` is the platform-agnostic consumer contract re-exported by the conditional Effect + entries. There is no general-purpose `internals.ts` entrypoint. ## Testing @@ -276,5 +310,6 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l - Targeted e2e tests own the expensive process/container Seam for full stack startup, parallel stacks, daemon lifecycle, and cleanup behavior. -The authoritative current service versions are `DEFAULT_VERSIONS` in `src/versions.ts`; package +The authoritative current service versions are the `defaultVersion` fields in +`src/ServiceCatalog.ts`; `DEFAULT_VERSIONS` is derived from that catalog, and package documentation should link to that source rather than copy its values. diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index 812c8e861a..e2b1d44a67 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -114,14 +114,22 @@ metadata and service data remain. `DaemonServer` exposes the local `Stack` Interface on the Unix socket. Current routes include: - `/health`, `/status`, and `/status/stream`; -- `/start`, `/stop`, and `/ready`; +- `/start`, `/stop`, and `POST /ready`; - per-service start, stop, restart, and readiness; - merged and per-service live logs plus buffered history; - functions and Edge Runtime reload. -State and log streams use SSE. Ordinary responses and typed failures use validated JSON shapes. -`RemoteStack` decodes that transport back into the same Effect `Stack` Interface used in -foreground mode, including `ServiceNotFoundError`, `ServiceReadyError`, and `StackBuildError`. +State and log streams use SSE. Readiness routes use `POST` with a validated readiness-policy body; +omitting an override sends the explicit `inherit` representation. Ordinary responses and typed +failures use validated JSON shapes. `RemoteStack` decodes that transport back into the same Effect +`Stack` Interface used in foreground mode, including `ServiceNotFoundError`, `ServiceReadyError`, +`StackBuildError`, and `StackReadinessError`. + +Functions and Edge Runtime reload routes also use validated JSON bodies. Resolved Functions +bundles may contain environment values, so they are deliberately excluded from daemon startup IPC, +query parameters, durable metadata, live state, logs, and rendered validation errors. The daemon +keeps only the active bundle in memory and writes the derived Edge Runtime file ephemerally with +owner-only permissions. The management socket is not the public local API endpoint. `ApiProxy` still owns the configured HTTP API port inside the daemon process. @@ -141,26 +149,35 @@ package. In particular: On normal `/stop`, the daemon gracefully stops the stack, signals HTTP shutdown after the response has had time to flush, disposes both managed runtimes, and removes live state/runtime paths. +A readiness deadline is terminal for that local runtime: the stack disposes its scoped resources, +the daemon returns the typed timeout response, and then the daemon shuts down. This prevents later +requests from relaunching processes after cleanup has already run. The boundary is deliberately +fail-closed across the whole daemon, rather than isolated to the service that timed out: once +processes and port leases are being released, the management and proxy servers cannot safely keep +advertising a usable runtime. This terminal path does not drain unrelated in-flight requests to +otherwise healthy services; callers must reconnect after starting a fresh daemon. + If the daemon has died, CLI stop/status detects a stale PID. Stop can use cleanup targets persisted in `stack.json` to force-remove known Docker containers before removing the stale state. This crash-recovery metadata is deliberately separate from user-facing `/status` connection data. ## Package entrypoints -| File | Reachability and role | -| --------------------- | ------------------------------------------------------------------------------------------------------------- | -| `src/daemon.ts` | Shared daemon protocol and lifecycle; receives runtime-specific HTTP-server factories. | -| `src/daemon-bun.ts` | Bun daemon Adapter. Exported as `@supabase/stack/daemon-bun` for compiled CLI dispatch. | -| `src/daemon-node.ts` | Node daemon Adapter. Intentionally file-URL-only: `node.ts` resolves its path and passes it to `daemonLayer`. | -| `src/DaemonServer.ts` | Unix-socket HTTP/SSE Adapter over `Stack`. | -| `src/RemoteStack.ts` | Remote Effect `Stack` Adapter over that transport. | -| `src/layers.ts` | Foreground, foreground-daemon, forked-daemon, and connect layer composition. | -| `src/StateManager.ts` | Durable metadata, live-state claims, scanning, stale-state removal, and deletion. | -| `src/effect.ts` | Effect-facing exports consumed by the CLI and advanced callers. | - -There is no `internals.ts`. `daemon-node.ts` is not a package export because Node root consumers -reach it by the file URL returned from `node.ts`; it is listed under `knip.entry` in `package.json` -so static unused-code analysis preserves that live entrypoint. +| File | Reachability and role | +| --------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/daemon.ts` | Shared daemon protocol and lifecycle; receives runtime-specific HTTP-server factories. | +| `src/daemon-bun.ts` | Bun daemon Adapter. Exported as `@supabase/stack/daemon-bun` for compiled CLI dispatch. | +| `src/daemon-node.ts` | Node daemon Adapter. Intentionally file-URL-only: the internal Node platform Adapter resolves its path. | +| `src/DaemonServer.ts` | Unix-socket HTTP/SSE Adapter over `Stack`. | +| `src/RemoteStack.ts` | Remote Effect `Stack` Adapter over that transport. | +| `src/layers.ts` | Foreground, foreground-daemon, forked-daemon, and connect layer composition. | +| `src/StateManager.ts` | Durable metadata, live-state claims, scanning, stale-state removal, and deletion. | +| `src/effect-*.ts` | Conditional Effect entries that bind consumer layers to Bun or Node. | +| `src/effect.ts` | Platform-agnostic Effect contracts re-exported by the conditional entries. | + +There is no `internals.ts`. `daemon-node.ts` is not a package export because the Node Effect +Adapter reaches it through the file URL returned by the internal platform module; it is listed +under `knip.entry` in `package.json` so static unused-code analysis preserves that live entrypoint. ## Compiled executable re-entry diff --git a/packages/stack/docs/service-versioning.md b/packages/stack/docs/service-versioning.md index d4c12efdfa..f8a3dd819c 100644 --- a/packages/stack/docs/service-versioning.md +++ b/packages/stack/docs/service-versioning.md @@ -38,10 +38,10 @@ The important separation is: ## Artifact Providers and Runtime Support -Every service in the Stack has one artifact definition, regardless of who maintains it. The -definition records its Docker image provider, tag convention, and whether a supported native -release is available. Runtime code consumes the resulting service resolution and does not need to -know which registry or release repository supplied it. +Every service in the Stack has one entry in `ServiceCatalog.ts`, regardless of who maintains it. +The entry records its default version, Docker image provider, tag convention, runtime support, and +any native release source. Runtime code consumes the resulting service resolution and does not +need to know which registry or release repository supplied it. Supabase-managed images currently retain their ECR, Docker Hub, and GHCR candidates. External services such as imgproxy, Mailpit, and Vector retain their upstream Docker images and are marked @@ -63,8 +63,8 @@ service definitions. The old Go CLI used `pkg/config/templates/Dockerfile` as a version manifest so Dependabot could bump image tags automatically. -The TypeScript stack exports a typed `DEFAULT_VERSIONS` manifest instead. That constant is the -built-in default version set for a given CLI release. +The TypeScript stack derives its typed `DEFAULT_VERSIONS` manifest from `ServiceCatalog.ts`. The +catalog values are the built-in default version set for a given CLI release. These defaults are the fallback for: diff --git a/packages/stack/package.json b/packages/stack/package.json index 5bc0c7d83f..d8c3f695d8 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -8,7 +8,11 @@ "bun": "./src/bun.ts", "default": "./src/node.ts" }, - "./effect": "./src/effect.ts", + "./effect": { + "bun": "./src/effect-bun.ts", + "default": "./src/effect-node.ts" + }, + "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, "scripts": { @@ -22,7 +26,6 @@ "dependencies": { "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", - "@supabase/config": "workspace:*", "@supabase/process-compose": "workspace:*", "effect": "catalog:" }, diff --git a/packages/stack/scripts/sync-versions-from-dockerfile.ts b/packages/stack/scripts/sync-versions-from-dockerfile.ts index 39e4592ea2..a35e4db24d 100644 --- a/packages/stack/scripts/sync-versions-from-dockerfile.ts +++ b/packages/stack/scripts/sync-versions-from-dockerfile.ts @@ -11,7 +11,7 @@ import { const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "../../.."); const dockerfilePath = path.join(repoRoot, "apps/cli-go/pkg/config/templates/Dockerfile"); -const versionsPath = path.join(repoRoot, "packages/stack/src/versions.ts"); +const catalogPath = path.join(repoRoot, "packages/stack/src/ServiceCatalog.ts"); const fromLinePattern = /^FROM\s+(.+):([^:\s]+)\s+AS\s+([^\s#]+)/i; @@ -76,43 +76,40 @@ export function readVersionManifestFromDockerfile(dockerfile: string): VersionMa return versions; } -function renderManifestKey(service: ServiceName): string { - return /^[a-zA-Z_$][\w$]*$/.test(service) ? service : JSON.stringify(service); -} - -export function renderDefaultVersions(versions: VersionManifest): string { - const lines = SERVICE_NAMES.map( - (service) => ` ${renderManifestKey(service)}: ${JSON.stringify(versions[service])},`, - ); - return ["export const DEFAULT_VERSIONS: VersionManifest = {", ...lines, "} as const;"].join("\n"); -} - export function syncDefaultVersionsSource(source: string, versions: VersionManifest): string { - const startMarker = "export const DEFAULT_VERSIONS: VersionManifest = {"; - const endMarker = "\n} as const;"; - const start = source.indexOf(startMarker); - if (start === -1) { - throw new Error("Could not find DEFAULT_VERSIONS declaration."); - } + let updated = source; + for (const service of SERVICE_NAMES) { + const nameMarker = ` name: ${JSON.stringify(service)},`; + const entryStart = updated.indexOf(nameMarker); + if (entryStart === -1) { + throw new Error(`Could not find catalog entry for '${service}'.`); + } - const end = source.indexOf(endMarker, start); - if (end === -1) { - throw new Error("Could not find DEFAULT_VERSIONS declaration end."); - } + const versionMarker = " defaultVersion: "; + const versionStart = updated.indexOf(versionMarker, entryStart + nameMarker.length); + if (versionStart === -1 || versionStart - entryStart > 300) { + throw new Error(`Could not find defaultVersion for '${service}'.`); + } + const versionEnd = updated.indexOf("\n", versionStart); + if (versionEnd === -1) { + throw new Error(`Could not find defaultVersion line end for '${service}'.`); + } - return `${source.slice(0, start)}${renderDefaultVersions(versions)}${source.slice( - end + endMarker.length, - )}`; + updated = `${updated.slice(0, versionStart)}${versionMarker}${JSON.stringify( + versions[service], + )},${updated.slice(versionEnd)}`; + } + return updated; } async function main() { const checkOnly = process.argv.includes("--check"); const dockerfile = await readFile(dockerfilePath, "utf8"); - const versionsSource = await readFile(versionsPath, "utf8"); + const catalogSource = await readFile(catalogPath, "utf8"); const versions = readVersionManifestFromDockerfile(dockerfile); - const syncedSource = syncDefaultVersionsSource(versionsSource, versions); + const syncedSource = syncDefaultVersionsSource(catalogSource, versions); - if (syncedSource === versionsSource) { + if (syncedSource === catalogSource) { console.log("DEFAULT_VERSIONS is already synced with the Dockerfile manifest."); return; } @@ -123,7 +120,7 @@ async function main() { return; } - await Bun.write(versionsPath, syncedSource); + await Bun.write(catalogPath, syncedSource); console.log("Synced DEFAULT_VERSIONS with the Dockerfile manifest."); } diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 632e467b09..9a3f98041c 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Option, Context, Duration, Schedule, Result } from "effect"; +import { Deferred, Effect, Layer, Option, Context, Schedule, Result } from "effect"; import { Headers, HttpBody, @@ -9,12 +9,11 @@ import { HttpServerRequest, HttpServerResponse, } from "effect/unstable/http"; -import { activationTimeoutSecondsForService, StackServiceActivator } from "./ServiceActivation.ts"; -import type { ServiceName } from "./versions.ts"; +import { StackServiceActivator } from "./ServiceActivation.ts"; +import type { ServiceName } from "./ServiceName.ts"; export interface ProxyConfig { readonly listenPort: number; - readonly activationTimeout?: Duration.Input; readonly gotruePort: number; readonly postgrestPort: number; readonly postgrestAdminPort: number; @@ -132,19 +131,17 @@ function makeProxyHandler( client: HttpClient.HttpClient, config: ProxyConfig, activator: StackServiceActivator["Service"], + signalTerminalFailure: Effect.Effect, opts: ProxyHandlerOptions, ) { return (req: HttpServerRequest.HttpServerRequest) => Effect.gen(function* () { - const activation = yield* activator - .activate(opts.service) - .pipe( - Effect.timeout( - config.activationTimeout ?? - Duration.seconds(activationTimeoutSecondsForService(opts.service)), - ), - Effect.result, - ); + const activation = yield* activator.activate(opts.service).pipe( + Effect.tapError((error) => + error._tag === "StackReadinessError" ? signalTerminalFailure : Effect.void, + ), + Effect.result, + ); if (Result.isFailure(activation)) { return HttpServerResponse.text("Service unavailable", { status: 503, @@ -225,6 +222,8 @@ export class ApiProxy extends Context.Service< ApiProxy, { readonly address: HttpServer.Address; + /** Completes when terminal lazy-activation failure requires daemon teardown. */ + readonly awaitTerminalFailure: Effect.Effect; } >()("local/ApiProxy") { static layer = ( @@ -239,13 +238,15 @@ export class ApiProxy extends Context.Service< const server = yield* HttpServer.HttpServer; const client = yield* HttpClient.HttpClient; const activator = yield* StackServiceActivator; + const terminalFailure = yield* Deferred.make(); + const signalTerminalFailure = Deferred.succeed(terminalFailure, void 0).pipe(Effect.asVoid); const routes = [ HttpRouter.route("*", "/health", HttpServerResponse.text("OK", { status: 200 })), HttpRouter.route( "*", "/.well-known/oauth-authorization-server", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "auth", backendPort: config.gotruePort, backendPath: "/.well-known/oauth-authorization-server", @@ -254,7 +255,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/auth/v1/verify", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "auth", backendPort: config.gotruePort, stripPrefix: "/auth/v1", @@ -263,7 +264,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/auth/v1/callback", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "auth", backendPort: config.gotruePort, stripPrefix: "/auth/v1", @@ -272,7 +273,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/auth/v1/authorize", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "auth", backendPort: config.gotruePort, stripPrefix: "/auth/v1", @@ -281,7 +282,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/auth/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "auth", backendPort: config.gotruePort, stripPrefix: "/auth/v1", @@ -291,7 +292,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/rest/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "postgrest", backendPort: config.postgrestPort, stripPrefix: "/rest/v1", @@ -301,7 +302,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/rest-admin/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "postgrest", backendPort: config.postgrestAdminPort, stripPrefix: "/rest-admin/v1", @@ -310,7 +311,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/graphql/v1", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "postgrest", backendPort: config.postgrestPort, backendPath: "/rpc/graphql", @@ -321,7 +322,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/functions/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "edge-runtime", backendPort: config.edgeRuntimePort, stripPrefix: "/functions/v1", @@ -333,7 +334,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/realtime/v1/api/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "realtime", backendPort: config.realtimePort, stripPrefix: "/realtime/v1", @@ -343,7 +344,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/realtime/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "realtime", backendPort: config.realtimePort, stripPrefix: "/realtime/v1", @@ -352,7 +353,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/storage/v1/s3/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "storage", backendPort: config.storagePort, stripPrefix: "/storage/v1", @@ -361,7 +362,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/storage/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "storage", backendPort: config.storagePort, stripPrefix: "/storage/v1", @@ -371,7 +372,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/pg/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "pgmeta", backendPort: config.pgmetaPort, stripPrefix: "/pg", @@ -380,7 +381,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/analytics/v1/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "analytics", backendPort: config.analyticsPort, stripPrefix: "/analytics/v1", @@ -389,7 +390,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/pooler/v2/*", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "pooler", backendPort: config.poolerPort, stripPrefix: "/pooler", @@ -398,7 +399,7 @@ export class ApiProxy extends Context.Service< HttpRouter.route( "*", "/mcp", - makeProxyHandler(client, config, activator, { + makeProxyHandler(client, config, activator, signalTerminalFailure, { service: "studio", backendPort: config.studioPort, backendPath: "/api/mcp", @@ -423,6 +424,7 @@ export class ApiProxy extends Context.Service< return { address: server.address, + awaitTerminalFailure: Deferred.await(terminalFailure), }; }), ); diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index 6e97bd0a6c..2d13427352 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -5,7 +5,7 @@ import { Effect, Layer, ManagedRuntime } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; -import { StackNotRunningError } from "./errors.ts"; +import { StackNotRunningError, StackReadinessError } from "./errors.ts"; import { StackServiceActivator } from "./ServiceActivation.ts"; import type { ServiceName } from "./versions.ts"; @@ -120,7 +120,11 @@ function buildProxyLayer( async function startProxy( config: ProxyConfig, activatorLayer?: Layer.Layer, -): Promise<{ url: string; dispose: () => Promise }> { +): Promise<{ + url: string; + dispose: () => Promise; + awaitTerminalFailure: () => Promise; +}> { const proxyRuntime = ManagedRuntime.make(buildProxyLayer(config, activatorLayer)); const proxy = await proxyRuntime.runPromise(ApiProxy); const addr = proxy.address; @@ -129,7 +133,11 @@ async function startProxy( const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; url = `http://${host}:${addr.port}`; } - return { url, dispose: () => proxyRuntime.dispose() }; + return { + url, + dispose: () => proxyRuntime.dispose(), + awaitTerminalFailure: () => proxyRuntime.runPromise(proxy.awaitTerminalFailure), + }; } describe("ApiProxy", () => { @@ -246,20 +254,22 @@ describe("ApiProxy", () => { } }); - test("returns 503 when service activation does not complete before the request deadline", async () => { + test("signals daemon teardown after a terminal activation failure", async () => { const activatorLayer = Layer.succeed(StackServiceActivator, { - activate: () => Effect.never, + activate: () => + Effect.fail( + new StackReadinessError({ + target: "postgrest", + timeoutMs: 30_000, + detail: "PostgREST did not become ready", + }), + ), }); - const proxy = await startProxy( - { ...configForPort(echoServer.port), activationTimeout: "10 millis" }, - activatorLayer, - ); + const proxy = await startProxy(configForPort(echoServer.port), activatorLayer); try { - const res = await fetch(`${proxy.url}/rest/v1/users`, { - signal: AbortSignal.timeout(1_000), - }); + const res = await fetch(`${proxy.url}/rest/v1/users`); expect(res.status).toBe(503); - expect(res.headers.get("retry-after")).toBe("1"); + await expect(proxy.awaitTerminalFailure()).resolves.toBeUndefined(); } finally { await proxy.dispose(); } diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts index b7ff757b7d..5dc58fe38d 100644 --- a/packages/stack/src/BinaryResolver.integration.test.ts +++ b/packages/stack/src/BinaryResolver.integration.test.ts @@ -18,7 +18,7 @@ import { afterEach } from "vitest"; import { BinaryResolver } from "./BinaryResolver.ts"; import { DownloadError } from "./errors.ts"; import { detectPlatform } from "./Platform.ts"; -import { nativeReleaseForService } from "./ServiceArtifacts.ts"; +import { nativeReleaseForService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const tempRoots: string[] = []; diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts index dbd68113b7..f6ee4070d4 100644 --- a/packages/stack/src/BinaryResolver.ts +++ b/packages/stack/src/BinaryResolver.ts @@ -8,8 +8,8 @@ import { nativeReleaseForService, type ArchiveFormat, type NativeReleaseArtifact, -} from "./ServiceArtifacts.ts"; -import type { ServiceName } from "./versions.ts"; +} from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; export interface BinarySpec { readonly service: ServiceName; diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts index 3aabc93834..d0576010bd 100644 --- a/packages/stack/src/BinaryResolver.unit.test.ts +++ b/packages/stack/src/BinaryResolver.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { nativeReleaseForService } from "./ServiceArtifacts.ts"; +import { nativeReleaseForService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const postgresVersion = DEFAULT_VERSIONS.postgres; diff --git a/packages/stack/src/CleanupTargets.ts b/packages/stack/src/CleanupTargets.ts index 47cb77a788..6de3b51cd9 100644 --- a/packages/stack/src/CleanupTargets.ts +++ b/packages/stack/src/CleanupTargets.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import type { ServiceName } from "./ServiceName.ts"; export interface CleanupTargets { readonly dockerContainerNames: ReadonlyArray; @@ -7,3 +8,6 @@ export interface CleanupTargets { export const CleanupTargetsSchema = Schema.Struct({ dockerContainerNames: Schema.Array(Schema.String), }); + +export const dockerContainerName = (service: ServiceName, apiPort: number): string => + `supabase-${service}-${apiPort}`; diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts index 7bfdf9fb94..88262482cb 100644 --- a/packages/stack/src/DaemonProtocol.ts +++ b/packages/stack/src/DaemonProtocol.ts @@ -4,6 +4,7 @@ import { StackStateSchema } from "./StateManager.ts"; const DaemonErrorCodeSchema = Schema.Literals([ "SERVICE_NOT_FOUND", "SERVICE_NOT_READY", + "STACK_READINESS_TIMEOUT", "STACK_BUILD_ERROR", ]); @@ -12,6 +13,7 @@ export const DaemonErrorResponseSchema = Schema.Struct({ error: Schema.String, service: Schema.optionalKey(Schema.String), exitCode: Schema.optionalKey(Schema.Number), + timeoutMs: Schema.optionalKey(Schema.Number), }); export type DaemonErrorResponse = typeof DaemonErrorResponseSchema.Type; diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts index e16ec4ac7f..7bd0988a41 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -4,6 +4,8 @@ import { Effect, Layer, ManagedRuntime, Stream } from "effect"; import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; +import { StackReadinessError } from "./errors.ts"; +import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; @@ -53,13 +55,23 @@ const MOCK_LOGS: ReadonlyArray = [ // Mock Stack // --------------------------------------------------------------------------- -function mockStack() { +function mockStack(options: { readonly startTimeoutMs?: number } = {}) { let stopped = false; const serviceCalls: string[] = []; + const functionReloads: FunctionsReloadConfig[] = []; const layer = Layer.succeed(Stack, { getInfo: () => Effect.succeed(MOCK_INFO), - start: () => Effect.void, + start: () => + options.startTimeoutMs === undefined + ? Effect.void + : Effect.fail( + new StackReadinessError({ + target: "stack", + timeoutMs: options.startTimeoutMs, + detail: `Timed out waiting for stack readiness after ${options.startTimeoutMs}ms`, + }), + ), stop: () => Effect.sync(() => { stopped = true; @@ -86,8 +98,9 @@ function mockStack() { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), - reloadFunctions: () => + reloadFunctions: (config) => Effect.sync(() => { + functionReloads.push(config ?? {}); serviceCalls.push("reload-functions"); }), reloadEdgeRuntime: () => @@ -132,9 +145,24 @@ function mockStack() { return stopped; }, serviceCalls, + functionReloads, }; } +const functionsBundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "shared-secret-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "function-secret-value" }, + }, + ], +}; + // --------------------------------------------------------------------------- // Layer builder // --------------------------------------------------------------------------- @@ -321,6 +349,29 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("restart:postgres"); }); + test("POST readiness routes validate the shared override representation", async () => { + const stackReady = await fetch(`${url}/ready`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "inherit" }), + }); + expect(stackReady.status).toBe(200); + + const serviceReady = await fetch(`${url}/services/postgres/ready`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "finite", timeoutMs: 100 }), + }); + expect(serviceReady.status).toBe(200); + + const malformed = await fetch(`${url}/ready`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "finite", timeoutMs: 0 }), + }); + expect(malformed.status).not.toBe(200); + }); + test("POST /edge-runtime/reload returns 200", async () => { const res = await fetch(`${url}/edge-runtime/reload`, { method: "POST", @@ -333,6 +384,43 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("reload-edge-runtime"); }); + test("POST /functions/reload validates and forwards its JSON body", async () => { + const res = await fetch(`${url}/functions/reload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ functions: functionsBundle }), + }); + + expect(res.status).toBe(200); + expect(mock.functionReloads).toContainEqual({ functions: functionsBundle }); + }); + + test("reload validation never renders resolved environment values", async () => { + const secret = "must-not-appear-in-errors"; + const res = await fetch(`${url}/functions/reload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + functions: { + env: { SECRET: secret }, + functions: [ + { + ...functionsBundle.functions[0], + entrypointPath: "relative/index.ts", + }, + ], + }, + }), + }); + const responseText = await res.text(); + + expect(res.status).toBe(400); + expect(responseText).toContain("Invalid Edge Functions reload payload"); + expect(JSON.parse(responseText)).toMatchObject({ code: "STACK_BUILD_ERROR" }); + expect(responseText).not.toContain(secret); + expect(responseText).not.toContain("relative/index.ts"); + }); + // ------------------------------------------------------------------------- // Error cases — service not found // ------------------------------------------------------------------------- @@ -358,6 +446,26 @@ describe("DaemonServer", () => { expect(body.error).toContain("unknown"); }); + test("a startup readiness timeout returns the typed failure and shuts down the daemon", async () => { + const freshRuntime = ManagedRuntime.make(buildDaemonLayer(mockStack({ startTimeoutMs: 75 }))); + try { + const daemon = await freshRuntime.runPromise(DaemonServer); + const shutdownPromise = freshRuntime.runPromise(daemon.awaitShutdown); + const response = await fetch(`${getUrl(daemon.address)}/start`, { method: "POST" }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + code: "STACK_READINESS_TIMEOUT", + error: "Timed out waiting for stack readiness after 75ms", + service: "stack", + timeoutMs: 75, + }); + await shutdownPromise; + } finally { + await freshRuntime.dispose(); + } + }); + // ------------------------------------------------------------------------- // Stop (tested last since it modifies daemon state) // ------------------------------------------------------------------------- diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 638029c885..7235f66054 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -8,7 +8,9 @@ import { } from "effect/unstable/http"; import * as Sse from "effect/unstable/encoding/Sse"; import type { DaemonErrorResponse } from "./DaemonProtocol.ts"; +import { FunctionsReloadConfigSchema } from "./functions.ts"; import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; +import { ReadyOptionsSchema } from "./StackConfig.ts"; // --------------------------------------------------------------------------- // Service @@ -18,6 +20,7 @@ export class DaemonServer extends Context.Service< DaemonServer, { readonly address: HttpServer.Address; + readonly beginShutdown: Effect.Effect; readonly awaitShutdown: Effect.Effect; } >()("stack/DaemonServer") { @@ -31,7 +34,7 @@ export class DaemonServer extends Context.Service< const server = yield* HttpServer.HttpServer; const shutdownDeferred = yield* Deferred.make(); const textEncoder = new TextEncoder(); - const errorResponse = (body: DaemonErrorResponse, status: 404 | 500) => + const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 500) => HttpServerResponse.jsonUnsafe(body, { status }); const notFoundResponse = (name: string) => errorResponse( @@ -50,6 +53,34 @@ export class DaemonServer extends Context.Service< ); const buildErrorResponse = (detail: string) => errorResponse({ code: "STACK_BUILD_ERROR", error: detail }, 500); + const invalidReloadPayloadResponse = () => + errorResponse( + { code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" }, + 400, + ); + const readinessTimeoutResponse = (target: string, timeoutMs: number, detail: string) => + errorResponse( + { + code: "STACK_READINESS_TIMEOUT", + error: detail, + service: target, + timeoutMs, + }, + 500, + ); + const beginShutdown = beforeShutdown.pipe( + Effect.ensuring( + // The HTTP module has no response-flushed hook. Delay the process + // shutdown signal long enough for the final JSON response to leave + // the socket. + Deferred.succeed(shutdownDeferred, void 0).pipe( + Effect.delay("25 millis"), + Effect.forkDetach, + ), + ), + ); + const terminalReadinessResponse = (target: string, timeoutMs: number, detail: string) => + beginShutdown.pipe(Effect.as(readinessTimeoutResponse(target, timeoutMs, detail))); // Helper: wrap an Effect Stream as a text/event-stream response const sseResponse = ( @@ -113,20 +144,29 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), HttpRouter.route( - "GET", + "POST", "/ready", - stack.waitAllReady().pipe( - Effect.as(HttpServerResponse.jsonUnsafe({ ok: true })), + Effect.gen(function* () { + const opts = yield* HttpServerRequest.schemaBodyJson(ReadyOptionsSchema); + yield* stack.waitAllReady(opts); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }).pipe( Effect.catchTag("ServiceReadyError", (e) => Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), @@ -136,18 +176,7 @@ export class DaemonServer extends Context.Service< "/stop", Effect.gen(function* () { yield* stack.stop(); - yield* beforeShutdown.pipe( - Effect.ensuring( - // The HTTP module has no response-flushed hook. Delay the - // process shutdown signal long enough for this small JSON - // response to leave the socket; stopDaemon also tolerates a - // dropped response and confirms termination by polling PID. - Deferred.succeed(shutdownDeferred, void 0).pipe( - Effect.delay("25 millis"), - Effect.forkDetach, - ), - ), - ); + yield* beginShutdown; return HttpServerResponse.jsonUnsafe({ ok: true }); }), ), @@ -219,15 +248,19 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), HttpRouter.route( - "GET", + "POST", "/services/:name/ready", Effect.gen(function* () { const routeParams = yield* HttpRouter.params; - yield* stack.waitReady(routeParams.name!); + const opts = yield* HttpServerRequest.schemaBodyJson(ReadyOptionsSchema); + yield* stack.waitReady(routeParams.name!, opts); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( Effect.catchTag("ServiceNotFoundError", (e) => @@ -239,6 +272,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), @@ -276,6 +312,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), @@ -283,13 +322,14 @@ export class DaemonServer extends Context.Service< "POST", "/functions/reload", Effect.gen(function* () { - const searchParams = yield* HttpServerRequest.ParsedSearchParams; - yield* stack.reloadFunctions({ - envFile: parseSingleParam(searchParams.envFile), - noVerifyJwt: parseBoolean(searchParams.noVerifyJwt), - }); + const body = yield* HttpServerRequest.schemaBodyJson(FunctionsReloadConfigSchema); + yield* stack.reloadFunctions(body); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -299,6 +339,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), @@ -310,6 +353,10 @@ export class DaemonServer extends Context.Service< yield* stack.reloadEdgeRuntime(body); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -319,6 +366,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail)), ), + Effect.catchTag("StackReadinessError", (e) => + terminalReadinessResponse(e.target, e.timeoutMs, e.detail), + ), ), ), ]; @@ -328,6 +378,7 @@ export class DaemonServer extends Context.Service< return { address: server.address, + beginShutdown, awaitShutdown: Deferred.await(shutdownDeferred), }; }), @@ -355,9 +406,3 @@ function parseSingleParam(value: string | ReadonlyArray | undefined): st if (value === undefined) return undefined; return typeof value === "string" ? value : value[0]; } - -function parseBoolean(value: string | ReadonlyArray | undefined): boolean | undefined { - const raw = parseSingleParam(value); - if (raw === undefined) return undefined; - return raw === "true"; -} diff --git a/packages/stack/src/JwtGenerator.ts b/packages/stack/src/JwtGenerator.ts index 83ac2115d6..ae4566cfd3 100644 --- a/packages/stack/src/JwtGenerator.ts +++ b/packages/stack/src/JwtGenerator.ts @@ -1,5 +1,4 @@ import { createHmac } from "node:crypto"; -import { Effect, Layer, Context } from "effect"; // Hardcoded opaque key defaults matching Go CLI (pkg/config/apikeys.go:19-20). // These are client-facing keys for local dev — SDKs use these, not JWTs directly. @@ -10,8 +9,7 @@ export const defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"; export const defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; /** - * Pure synchronous JWT generation. Used both by the JwtGenerator service - * and directly in createStack() where JWTs are needed before layers run. + * Pure synchronous JWT generation used while resolving stack configuration. */ export function generateJwt(secret: string, role: string): string { const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); @@ -38,16 +36,3 @@ export function generateJwks(secret: string): string { ], }); } - -export class JwtGenerator extends Context.Service< - JwtGenerator, - { - readonly generate: (secret: string, role: string) => Effect.Effect; - readonly generateJwks: (secret: string) => Effect.Effect; - } ->()("local/JwtGenerator") { - static layer: Layer.Layer = Layer.succeed(this, { - generate: (secret: string, role: string) => Effect.sync(() => generateJwt(secret, role)), - generateJwks: (secret: string) => Effect.sync(() => generateJwks(secret)), - }); -} diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts new file mode 100644 index 0000000000..84a2b78c49 --- /dev/null +++ b/packages/stack/src/LocalStack.ts @@ -0,0 +1,934 @@ +import { LogBuffer, Orchestrator } from "@supabase/process-compose"; +import { ServiceNotFoundError } from "@supabase/process-compose"; +import type { ResolvedGraph, ServiceReadyError } from "@supabase/process-compose"; +import { + Context, + Deferred, + Duration, + Effect, + Equal, + FileSystem, + Layer, + Path, + Ref, + Schema, + Semaphore, + Stream, + SubscriptionRef, +} from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { CleanupTargets } from "./CleanupTargets.ts"; +import { cleanupLocalStackResources } from "./cleanup.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; +import { + clearFunctionsRuntimeConfig, + configureFunctionsRuntime, + resolvedFunctionsBundleSchemaForProject, + type ResolvedFunctionsBundle, +} from "./functions.ts"; +import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import type { PortLease } from "./PortAllocator.ts"; +import { + activationTargetsForService, + eagerServices, + lifecycleTargetsForService, + StackServiceActivator, +} from "./ServiceActivation.ts"; +import { portFieldsForService } from "./ServicePorts.ts"; +import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; +import { StackPreparation } from "./StackPreparation.ts"; +import type { PreparedStackArtifacts } from "./StackPreparation.ts"; +import { + enabledServicesForConfig, + StackBuilder, + validateResolvedConfig, + versionsForConfig, +} from "./StackBuilder.ts"; +import { resolveReadinessPolicy } from "./StackConfig.ts"; +import type { ReadinessPolicy, ReadyOptions, ResolvedStackConfig } from "./StackConfig.ts"; +import { projectStackStates, type StackServiceProjectionCatalog } from "./StackStateProjection.ts"; +import { StackServiceState } from "./StackServiceState.ts"; +import { Stack } from "./Stack.ts"; +import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; +import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; + +type LifecyclePhase = + | "idle" + | "preparing" + | "prepared" + | "starting" + | "running" + | "stopping" + | "stopped" + | "disposed"; + +type StackService = typeof Stack.Service; + +/** Private signal used by the Promise adapter to close its enclosing managed runtime. */ +export class LocalStackLifecycle extends Context.Service< + LocalStackLifecycle, + { + readonly awaitDisposed: Effect.Effect; + readonly isDisposed: Effect.Effect; + } +>()("stack/LocalStackLifecycle") {} + +interface RuntimeState { + readonly orchestrator: Orchestrator["Service"]; + readonly graph: ResolvedGraph; + readonly serviceProjection: StackServiceProjectionCatalog; +} + +const initialPublicStates = (config: ResolvedStackConfig): ReadonlyArray => + enabledServicesForConfig(config).map( + (name) => + new StackServiceState({ + name, + status: "Pending", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, + }), + ); + +const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { + const apiUrl = `http://127.0.0.1:${config.apiPort}`; + return { + url: apiUrl, + dbUrl: `postgresql://postgres:postgres@127.0.0.1:${config.dbPort}/postgres`, + publishableKey: config.publishableKey, + secretKey: config.secretKey, + anonJwt: config.anonJwt, + serviceRoleJwt: config.serviceRoleJwt, + serviceEndpoints: { + ...(config.auth === false ? {} : { auth: `${apiUrl}/auth/v1` }), + ...(config.postgrest === false ? {} : { postgrest: `${apiUrl}/rest/v1` }), + ...(config.edgeRuntime === false + ? {} + : { + functions: `${apiUrl}/functions/v1`, + edge_runtime: `${apiUrl}/functions/v1`, + }), + ...(config.realtime === false ? {} : { realtime: `${apiUrl}/realtime/v1` }), + ...(config.storage === false + ? {} + : { + storage: `${apiUrl}/storage/v1`, + storage_s3: `${apiUrl}/storage/v1/s3`, + }), + ...(config.imgproxy === false || config.startupMode === "lazy" + ? {} + : { imgproxy: `http://127.0.0.1:${config.imgproxy.port}` }), + ...(config.mailpit === false + ? {} + : { + mailpit: `http://127.0.0.1:${config.mailpit.port}`, + mailpit_smtp: `smtp://127.0.0.1:${config.mailpit.smtpPort}`, + mailpit_pop3: `pop3://127.0.0.1:${config.mailpit.pop3Port}`, + }), + ...(config.pgmeta === false ? {} : { pgmeta: `${apiUrl}/pg` }), + ...(config.studio === false ? {} : { studio: `http://127.0.0.1:${config.studio.port}` }), + ...(config.analytics === false ? {} : { analytics: `${apiUrl}/analytics/v1` }), + ...(config.pooler === false + ? {} + : { + pooler: `postgresql://postgres:postgres@127.0.0.1:${config.pooler.port}/postgres`, + pooler_admin: `http://127.0.0.1:${config.pooler.apiPort}`, + }), + }, + }; +}; + +const changedStatesBetween = ( + previous: ReadonlyArray | undefined, + current: ReadonlyArray, +): ReadonlyArray => { + if (previous === undefined) { + return current; + } + + const previousByName = new Map(previous.map((state) => [state.name, state] as const)); + return current.filter((state) => !Equal.equals(previousByName.get(state.name), state)); +}; + +/** + * The private in-process Stack implementation. Its scoped construction owns + * lifecycle state once and publishes both public seams from that same state. + */ +export const localStackLayer = ( + config: ResolvedStackConfig, + portLease: PortLease, +): Layer.Layer< + Stack | StackServiceActivator | LocalStackLifecycle, + StackBuildError, + | StackBuilder + | StackPreparation + | ChildProcessSpawner.ChildProcessSpawner + | StackMetadataPersistence + | FileSystem.FileSystem + | Path.Path +> => + Layer.effectContext( + Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const metadataPersistence = yield* StackMetadataPersistence; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Effect.scope; + + const info = stackInfoFor(config); + const enabledServices = enabledServicesForConfig(config); + const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); + const phaseRef = yield* Ref.make("idle"); + const functionsBundleRef = yield* Ref.make( + config.functions === false ? undefined : config.functions, + ); + const disposedSignal = yield* Deferred.make(); + const lifecycleLock = Semaphore.makeUnsafe(1); + const projectionLock = Semaphore.makeUnsafe(1); + + const logBufferServices = yield* Layer.buildWithScope(LogBuffer.layer, scope); + const logBuffer = Context.get(logBufferServices, LogBuffer); + + const updateState = (nextState: StackServiceState) => + SubscriptionRef.update(stateRef, (current) => { + const previous = current.find((entry) => entry.name === nextState.name); + if (Equal.equals(previous, nextState)) { + return current; + } + return current.some((entry) => entry.name === nextState.name) + ? current.map((entry) => (entry.name === nextState.name ? nextState : entry)) + : [...current, nextState]; + }); + + const syncProjectedStates = ( + orchestrator: Orchestrator["Service"], + serviceProjection: StackServiceProjectionCatalog, + ) => + Effect.gen(function* () { + const rawStates = yield* orchestrator.getAllStates(); + yield* Effect.forEach(projectStackStates(rawStates, serviceProjection), updateState, { + discard: true, + }); + }).pipe(projectionLock.withPermit); + + const requireKnownService = (name: string) => + Effect.gen(function* () { + const currentStates = SubscriptionRef.getUnsafe(stateRef); + const match = currentStates.find((state) => state.name === name); + if (match === undefined) { + return yield* Effect.fail(new ServiceNotFoundError({ name })); + } + return match; + }); + const requireKnownServiceName = ( + name: string, + ): Effect.Effect => + Effect.gen(function* () { + yield* requireKnownService(name); + const service = SERVICE_NAMES.find((candidate) => candidate === name); + if (service === undefined) { + return yield* Effect.fail(new ServiceNotFoundError({ name })); + } + return service; + }); + + let preparedArtifacts: PreparedStackArtifacts | undefined; + let prepareDeferred: Deferred.Deferred | undefined; + let runtimeState: RuntimeState | undefined; + let runtimeDeferred: Deferred.Deferred | undefined; + let exactCleanupTargets: CleanupTargets | undefined; + + const ensurePrepared = Effect.suspend(() => { + if (preparedArtifacts !== undefined) { + return Effect.succeed(preparedArtifacts); + } + if (prepareDeferred !== undefined) { + return Deferred.await(prepareDeferred); + } + + const deferred = Deferred.makeUnsafe(); + prepareDeferred = deferred; + + const effect = Effect.gen(function* () { + yield* validateResolvedConfig(config); + yield* Ref.set(phaseRef, "preparing"); + + let prepared: PreparedStackArtifacts | undefined; + yield* preparation + .prepareEvents({ + mode: config.mode, + services: enabledServicesForConfig(config), + versions: versionsForConfig(config), + }) + .pipe( + Stream.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to prepare stack assets", + cause, + }), + ), + ) + .pipe( + Stream.runForEach((event) => { + switch (event._tag) { + case "ServiceDownloadStarted": + return updateState( + new StackServiceState({ + name: event.service, + status: "Downloading", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, + }), + ); + case "ServiceDownloadFinished": + return updateState( + new StackServiceState({ + name: event.service, + status: "Pending", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, + }), + ); + case "PreparationCompleted": + return Effect.sync(() => { + prepared = event.artifacts; + }); + } + }), + ); + + if (prepared === undefined) { + return yield* Effect.fail( + new StackBuildError({ + detail: "Stack preparation completed without prepared artifacts", + }), + ); + } + + yield* Ref.set(phaseRef, "prepared"); + return prepared; + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + preparedArtifacts = value; + }), + ), + Effect.onError(() => Ref.set(phaseRef, "idle")), + Effect.ensuring( + Effect.sync(() => { + prepareDeferred = undefined; + }), + ), + ); + + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); + return yield* Deferred.await(deferred); + }); + }); + + const ensureRuntime = Effect.suspend(() => { + if (runtimeState !== undefined) { + return Effect.succeed(runtimeState); + } + if (runtimeDeferred !== undefined) { + return Deferred.await(runtimeDeferred); + } + + const deferred = Deferred.makeUnsafe(); + runtimeDeferred = deferred; + + const effect = Effect.gen(function* () { + const prepared = yield* ensurePrepared; + const { graph, serviceProjection, cleanupTargets } = yield* builder.build( + config, + prepared, + ); + exactCleanupTargets = cleanupTargets; + + yield* metadataPersistence.persistCleanupTargets(cleanupTargets).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to persist stack cleanup metadata", + cause, + }), + ), + ); + + const orchLayer = Orchestrator.layer(graph).pipe( + Layer.provide(Layer.succeed(LogBuffer, logBuffer)), + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + const orchServices = yield* Layer.buildWithScope(orchLayer, scope); + const orchestrator = Context.get(orchServices, Orchestrator); + + yield* syncProjectedStates(orchestrator, serviceProjection); + yield* orchestrator.allStateChanges().pipe( + Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), + Effect.ignore, + Effect.forkIn(scope), + ); + + return { + orchestrator, + graph, + serviceProjection, + } satisfies RuntimeState; + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + runtimeState = value; + }), + ), + Effect.ensuring( + Effect.sync(() => { + runtimeDeferred = undefined; + }), + ), + ); + + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); + return yield* Deferred.await(deferred); + }); + }); + + let disposed = false; + let disposing = false; + const runtimeHost = Effect.gen(function* () { + const prepared = yield* ensurePrepared; + const platform = yield* detectPlatform; + const edgeRuntimeResolution = prepared.resolutions["edge-runtime"]; + return { + hostname: + edgeRuntimeResolution?.type === "docker" ? dockerHostAddress(platform.os) : "127.0.0.1", + }; + }); + const providePlatform = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + const decodeFunctionsBundle = (bundle: unknown) => + Schema.decodeUnknownEffect(resolvedFunctionsBundleSchemaForProject(config.projectDir))( + bundle, + ).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Invalid Edge Functions bundle", + cause, + }), + ), + ); + const configureFunctions = ( + nextConfig: ResolvedStackConfig, + bundle: ResolvedFunctionsBundle | undefined, + ): Effect.Effect => + Effect.gen(function* () { + yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost, bundle)); + }).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to configure Edge Functions", + cause, + }), + ), + ); + const configWithEdgeRuntimeOptions = ( + opts: EdgeRuntimeReloadConfig, + ): Effect.Effect => + Effect.gen(function* () { + if (config.edgeRuntime === false || opts.edgeRuntime.enabled === false) { + return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + } + + return { + ...config, + edgeRuntime: { + ...config.edgeRuntime, + enabled: opts.edgeRuntime.enabled ?? config.edgeRuntime.enabled, + inspectorPort: opts.edgeRuntime.inspectorPort ?? config.edgeRuntime.inspectorPort, + policy: opts.edgeRuntime.policy ?? config.edgeRuntime.policy, + env: opts.edgeRuntime.env ?? config.edgeRuntime.env, + }, + }; + }); + const publicAllStateChanges = () => + SubscriptionRef.changes(stateRef).pipe( + Stream.mapAccum< + ReadonlyArray | undefined, + ReadonlyArray, + StackServiceState + >( + () => undefined, + (previous, current) => [current, changedStatesBetween(previous, current)], + ), + ); + const withLifecycleLock = lifecycleLock.withPermit; + const syncRuntimeProjectedStates = (runtime: RuntimeState) => + syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); + const serviceStartOptions = { + beforeStart: (name: string) => portLease.reserve(portFieldsForService(name)), + beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), + }; + const knownServiceError = (service: string, cause: ServiceNotFoundError) => + new StackBuildError({ + detail: `Prepared graph does not contain enabled service ${service}`, + cause, + }); + const beginStartTargets = ( + root: ServiceName, + allowExplicitlyStopped: ReadonlySet, + ) => + Effect.gen(function* () { + const runtime = yield* ensureRuntime; + const targets = activationTargetsForService(enabledServices, root); + const targetClosure = new Set( + targets.flatMap((target) => + runtime.graph.startOrderFor(target).map((definition) => definition.name), + ), + ); + + for (const dependency of targetClosure) { + const state = yield* runtime.orchestrator + .getState(dependency) + .pipe( + Effect.catchTag("ServiceNotFoundError", (cause) => + Effect.fail(knownServiceError(dependency, cause)), + ), + ); + const publicDependency = SERVICE_NAMES.find((candidate) => candidate === dependency); + if ( + state.desired === "stopped" && + publicDependency !== undefined && + !allowExplicitlyStopped.has(publicDependency) + ) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Cannot activate ${root} because dependency ${dependency} was explicitly stopped`, + }), + ); + } + } + + for (const target of targets) { + yield* runtime.orchestrator + .startService(target, serviceStartOptions) + .pipe( + Effect.catchTag("ServiceNotFoundError", (cause) => + Effect.fail(knownServiceError(target, cause)), + ), + ); + } + return { runtime, targets }; + }); + const waitForTargets = ({ + runtime, + targets, + }: { + readonly runtime: RuntimeState; + readonly targets: ReadonlyArray; + }) => + Effect.gen(function* () { + yield* Effect.forEach( + targets, + (target) => + runtime.orchestrator + .waitReady(target) + .pipe( + Effect.catchTag("ServiceNotFoundError", (cause) => + Effect.fail(knownServiceError(target, cause)), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + yield* syncRuntimeProjectedStates(runtime); + }); + const inspectStartedTargets = (root: ServiceName) => + Effect.gen(function* () { + const runtime = yield* ensureRuntime; + const targets = activationTargetsForService(enabledServices, root); + const states = yield* Effect.forEach(targets, (target) => + runtime.orchestrator + .getState(target) + .pipe( + Effect.catchTag("ServiceNotFoundError", (cause) => + Effect.fail(knownServiceError(target, cause)), + ), + ), + ); + if (states.some((state) => state.desired !== "running")) { + return undefined; + } + return { + runtime, + targets, + ready: states.every( + (state) => + state.status === "Healthy" || (state.status === "Stopped" && state.exitCode === 0), + ), + }; + }); + const requireRunningPhase = Effect.gen(function* () { + const phase = yield* Ref.get(phaseRef); + if (phase !== "running") { + return yield* Effect.fail(new StackNotRunningError({ phase })); + } + }); + const requireMutable = (operation: string) => + Effect.suspend(() => + disposed || disposing + ? Effect.fail( + new StackBuildError({ + detail: `Cannot ${operation} after stack disposal has begun`, + }), + ) + : Effect.void, + ); + const disposeOnce = () => + Effect.suspend(() => { + disposing = true; + return Effect.gen(function* () { + if (disposed) { + return; + } + disposed = true; + yield* Ref.set(phaseRef, "stopping"); + yield* cleanupLocalStackResources({ + stop: () => + runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), + cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, + config, + }).pipe( + Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))), + Effect.ensuring(portLease.releaseAll), + Effect.ensuring(Ref.set(phaseRef, "disposed")), + ); + }).pipe(withLifecycleLock); + }).pipe( + Effect.ensuring(Deferred.succeed(disposedSignal, undefined).pipe(Effect.asVoid)), + Effect.uninterruptible, + ); + + const withReadinessPolicy = ( + effect: Effect.Effect, + target: string, + readyOptions?: ReadyOptions, + ): Effect.Effect => { + const policy: ReadinessPolicy = resolveReadinessPolicy({ + readyOptions, + stackPolicy: config.readiness, + }); + if (policy.mode === "infinite") { + return effect; + } + return effect.pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(policy.timeoutMs), + orElse: () => + Effect.fail( + new StackReadinessError({ + target, + timeoutMs: policy.timeoutMs, + detail: `Timed out waiting for ${target} readiness after ${policy.timeoutMs}ms`, + }), + ), + }), + ); + }; + const cleanupOnReadinessFailure = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.catchTag("StackReadinessError", (error) => + disposeOnce().pipe(Effect.andThen(Effect.fail(error))), + ), + ); + yield* Effect.addFinalizer(disposeOnce); + + const activateService = (name: ServiceName) => + Effect.gen(function* () { + yield* requireRunningPhase; + const service = yield* requireKnownServiceName(name); + const existing = yield* inspectStartedTargets(service); + if (existing?.ready === true) { + // Close the race with a concurrent stack stop before taking + // the lock-free healthy-request fast path. + yield* requireRunningPhase; + return; + } + if (existing !== undefined) { + yield* waitForTargets(existing).pipe((effect) => withReadinessPolicy(effect, name)); + return; + } + const started = yield* Effect.gen(function* () { + yield* requireRunningPhase; + const concurrentlyStarted = yield* inspectStartedTargets(service); + if (concurrentlyStarted !== undefined) return concurrentlyStarted; + return yield* beginStartTargets(service, new Set()); + }).pipe(withLifecycleLock); + yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + }).pipe(cleanupOnReadinessFailure); + + const stack = { + getInfo: () => Effect.succeed(info), + start: () => { + let serviceStartupBegan = false; + return Effect.gen(function* () { + yield* requireMutable("start"); + yield* Ref.set(phaseRef, "starting"); + const runtime = yield* ensureRuntime; + yield* configureFunctions(config, yield* Ref.get(functionsBundleRef)); + serviceStartupBegan = true; + + if (config.startupMode === "lazy") { + const readiness: Array> = []; + if ( + runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") + ) { + yield* runtime.orchestrator + .startService("postgres-init", serviceStartOptions) + .pipe( + Effect.catchTag("ServiceNotFoundError", (cause) => + Effect.fail(knownServiceError("postgres-init", cause)), + ), + ); + readiness.push( + runtime.orchestrator + .waitReady("postgres-init") + .pipe( + Effect.catchTag("ServiceNotFoundError", (cause) => + Effect.fail(knownServiceError("postgres-init", cause)), + ), + ), + ); + } + for (const service of eagerServices(enabledServices)) { + const started = yield* beginStartTargets( + service, + new Set(lifecycleTargetsForService(enabledServices, service)), + ); + readiness.push(waitForTargets(started)); + } + yield* Effect.all(readiness, { concurrency: "unbounded", discard: true }).pipe( + (effect) => withReadinessPolicy(effect, "stack"), + ); + } else { + yield* runtime.orchestrator.start(serviceStartOptions); + yield* runtime.orchestrator + .waitAllReady() + .pipe((effect) => withReadinessPolicy(effect, "stack")); + yield* syncRuntimeProjectedStates(runtime); + } + yield* Ref.set(phaseRef, "running"); + }).pipe( + Effect.onError(() => Ref.set(phaseRef, "stopped")), + withLifecycleLock, + Effect.onError(() => (serviceStartupBegan ? disposeOnce() : Effect.void)), + ); + }, + stop: () => + Effect.gen(function* () { + if (disposed) { + return; + } + if (runtimeState === undefined) { + yield* Ref.set(phaseRef, "stopped"); + return; + } + yield* Ref.set(phaseRef, "stopping"); + yield* runtimeState.orchestrator.stop(); + yield* Ref.set(phaseRef, "stopped"); + }).pipe(withLifecycleLock), + dispose: disposeOnce, + startService: (name) => + Effect.gen(function* () { + const started = yield* Effect.gen(function* () { + yield* requireMutable(`start service ${name}`); + const service = yield* requireKnownServiceName(name); + return yield* beginStartTargets( + service, + new Set(lifecycleTargetsForService(enabledServices, service)), + ); + }).pipe(withLifecycleLock); + yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + }).pipe(cleanupOnReadinessFailure), + stopService: (name) => + Effect.gen(function* () { + yield* requireMutable(`stop service ${name}`); + const service = yield* requireKnownServiceName(name); + const runtime = yield* ensureRuntime; + for (const target of lifecycleTargetsForService( + enabledServices, + service, + ).toReversed()) { + yield* runtime.orchestrator.stopService(target); + } + }).pipe(withLifecycleLock), + restartService: (name) => + Effect.gen(function* () { + const started = yield* Effect.gen(function* () { + yield* requireMutable(`restart service ${name}`); + const service = yield* requireKnownServiceName(name); + const runtime = yield* ensureRuntime; + yield* runtime.orchestrator.restartService(service, serviceStartOptions); + return { runtime, targets: [service] }; + }).pipe(withLifecycleLock); + yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + }).pipe(cleanupOnReadinessFailure), + reloadFunctions: (opts) => + Effect.gen(function* () { + const started = yield* Effect.gen(function* () { + yield* requireMutable("reload functions"); + yield* requireKnownService("edge-runtime"); + const currentBundle = yield* Ref.get(functionsBundleRef); + const nextBundle = + opts?.functions === undefined + ? currentBundle + : yield* decodeFunctionsBundle(opts.functions); + yield* configureFunctions(config, nextBundle); + yield* Ref.set(functionsBundleRef, nextBundle); + const runtime = yield* ensureRuntime; + const state = yield* runtime.orchestrator.getState("edge-runtime"); + if (state.desired !== "running") { + return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); + } + yield* runtime.orchestrator.restartService("edge-runtime", serviceStartOptions); + return { runtime, targets: ["edge-runtime"] as const }; + }).pipe(withLifecycleLock); + yield* waitForTargets(started).pipe((effect) => + withReadinessPolicy(effect, "edge-runtime"), + ); + }).pipe(cleanupOnReadinessFailure), + reloadEdgeRuntime: (opts) => + Effect.gen(function* () { + const started = yield* Effect.gen(function* () { + yield* requireMutable("reload Edge Runtime"); + yield* requireKnownService("edge-runtime"); + const nextConfig = yield* configWithEdgeRuntimeOptions(opts); + const currentBundle = yield* Ref.get(functionsBundleRef); + const nextBundle = + opts.functions === undefined + ? currentBundle + : yield* decodeFunctionsBundle(opts.functions); + const prepared = yield* ensurePrepared; + const runtime = yield* ensureRuntime; + const buildResult = yield* builder.build(nextConfig, prepared); + const edgeRuntimeDef = buildResult.graph.startOrder.find( + (def) => def.name === "edge-runtime", + ); + + if (edgeRuntimeDef === undefined) { + return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + } + + yield* configureFunctions(nextConfig, nextBundle); + yield* Ref.set(functionsBundleRef, nextBundle); + yield* runtime.orchestrator + .updateServiceDefinition("edge-runtime", edgeRuntimeDef) + .pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to update edge-runtime service definition", + cause, + }), + ), + ); + const state = yield* runtime.orchestrator.getState("edge-runtime"); + if (state.desired !== "running") { + return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); + } + yield* runtime.orchestrator.restartService("edge-runtime", serviceStartOptions); + return { runtime, targets: ["edge-runtime"] as const }; + }).pipe(withLifecycleLock); + yield* waitForTargets(started).pipe((effect) => + withReadinessPolicy(effect, "edge-runtime"), + ); + }).pipe(cleanupOnReadinessFailure), + getState: (name) => + Effect.gen(function* () { + const currentStates = SubscriptionRef.getUnsafe(stateRef); + const match = currentStates.find((state) => state.name === name); + if (match === undefined) { + return yield* Effect.fail(new ServiceNotFoundError({ name })); + } + return match; + }), + getAllStates: () => Effect.sync(() => SubscriptionRef.getUnsafe(stateRef)), + stateChanges: (name) => + Effect.gen(function* () { + yield* requireKnownService(name); + return Stream.filter(publicAllStateChanges(), (state) => state.name === name); + }), + allStateChanges: publicAllStateChanges, + waitReady: (name, opts) => + Effect.gen(function* () { + const phase = yield* Ref.get(phaseRef); + if (phase !== "running") { + return yield* Effect.fail( + new StackBuildError({ + detail: `Cannot wait for service ${name} while the stack is ${phase}`, + }), + ); + } + yield* requireKnownServiceName(name); + const runtime = yield* ensureRuntime; + yield* runtime.orchestrator + .waitReady(name) + .pipe((effect) => withReadinessPolicy(effect, name, opts)); + yield* syncRuntimeProjectedStates(runtime); + }).pipe(cleanupOnReadinessFailure), + waitAllReady: (opts) => + Effect.gen(function* () { + const phase = yield* Ref.get(phaseRef); + if (phase !== "running") { + return yield* Effect.fail( + new StackBuildError({ + detail: `Cannot wait for stack readiness while the stack is ${phase}`, + }), + ); + } + const runtime = yield* ensureRuntime; + yield* runtime.orchestrator + .waitAllReady() + .pipe((effect) => withReadinessPolicy(effect, "stack", opts)); + yield* syncRuntimeProjectedStates(runtime); + }).pipe(cleanupOnReadinessFailure), + subscribeLogs: (name) => logBuffer.subscribe(name), + subscribeAllLogs: (services) => + services === undefined || services.length === 0 + ? logBuffer.subscribeAll() + : logBuffer + .subscribeAll() + .pipe(Stream.filter((entry) => services.includes(entry.service))), + logHistory: (name, limit) => logBuffer.history(name, limit), + logHistoryAll: (limit, services) => logBuffer.historyAll(limit, services), + } satisfies StackService; + + return Context.make(Stack, stack).pipe( + Context.add(StackServiceActivator, { activate: activateService }), + Context.add(LocalStackLifecycle, { + awaitDisposed: Deferred.await(disposedSignal), + isDisposed: Effect.sync(() => disposed), + }), + ); + }), + ); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index dc520bab1e..7d2fc3f781 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -4,9 +4,11 @@ import { Effect, Fiber, Layer, ManagedRuntime, Stream } from "effect"; import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; -import { StackBuildError } from "./errors.ts"; +import { StackBuildError, StackReadinessError } from "./errors.ts"; +import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { RemoteStack } from "./RemoteStack.ts"; -import { Stack, type StackInfo } from "./Stack.ts"; +import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts"; +import type { ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; @@ -75,11 +77,15 @@ function mockStack( readonly startServiceBuildError?: string; readonly startServiceReadyError?: string; readonly waitReadyBuildError?: string; + readonly waitReadyTimeoutMs?: number; readonly restartServiceReadyError?: string; } = {}, ) { let stopped = false; const serviceCalls: string[] = []; + const functionReloads: FunctionsReloadConfig[] = []; + const edgeRuntimeReloads: EdgeRuntimeReloadConfig[] = []; + const readinessCalls: Array<{ readonly target: string; readonly options?: ReadyOptions }> = []; const layer = Layer.succeed(Stack, { getInfo: () => Effect.succeed(MOCK_INFO), @@ -126,12 +132,14 @@ function mockStack( : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), - reloadFunctions: () => + reloadFunctions: (config) => Effect.sync(() => { + functionReloads.push(config ?? {}); serviceCalls.push("reload-functions"); }), - reloadEdgeRuntime: () => + reloadEdgeRuntime: (config) => Effect.sync(() => { + edgeRuntimeReloads.push(config); serviceCalls.push("reload-edge-runtime"); }), getState: (name: string) => { @@ -146,18 +154,29 @@ function mockStack( : Effect.fail(new ServiceNotFoundError({ name })); }, allStateChanges: () => Stream.fromIterable(MOCK_STATES), - waitReady: (name: string) => { + waitReady: (name: string, readyOptions?: ReadyOptions) => { const match = MOCK_STATES.find((s) => s.name === name); if (match === undefined) return Effect.fail(new ServiceNotFoundError({ name })); if (options.waitReadyBuildError !== undefined) { return Effect.fail(new StackBuildError({ detail: options.waitReadyBuildError })); } + if (options.waitReadyTimeoutMs !== undefined) { + return Effect.fail( + new StackReadinessError({ + target: name, + timeoutMs: options.waitReadyTimeoutMs, + detail: `Timed out waiting for ${name}`, + }), + ); + } return Effect.sync(() => { + readinessCalls.push({ target: name, options: readyOptions }); serviceCalls.push(`ready:${name}`); }); }, - waitAllReady: () => + waitAllReady: (readyOptions?: ReadyOptions) => Effect.sync(() => { + readinessCalls.push({ target: "stack", options: readyOptions }); serviceCalls.push("ready:all"); }), subscribeLogs: (name: string) => @@ -185,9 +204,26 @@ function mockStack( return stopped; }, serviceCalls, + readinessCalls, + functionReloads, + edgeRuntimeReloads, }; } +const functionsBundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "shared-secret-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "function-secret-value" }, + }, + ], +}; + // --------------------------------------------------------------------------- // Layer builder — DaemonServer backed by mock Stack on TCP port // --------------------------------------------------------------------------- @@ -288,9 +324,15 @@ describe("RemoteStack integration", () => { expect(exit._tag).toBe("Failure"); }); - test("waitReady delegates to the daemon coordinator", async () => { - await clientRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.waitReady("auth"))); + test("waitReady passes one validated finite override through the daemon", async () => { + await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => stack.waitReady("auth", { mode: "finite", timeoutMs: 250 })), + ); expect(mock.serviceCalls).toContain("ready:auth"); + expect(mock.readinessCalls).toContainEqual({ + target: "auth", + options: { mode: "finite", timeoutMs: 250 }, + }); }); test("waitReady rejects dot path segments locally", async () => { @@ -301,9 +343,38 @@ describe("RemoteStack integration", () => { expect(mock.serviceCalls).not.toContain("ready:all"); }); - test("waitAllReady delegates to the daemon coordinator", async () => { + test("waitAllReady sends explicit inherit semantics to the daemon", async () => { await clientRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.waitAllReady())); expect(mock.serviceCalls).toContain("ready:all"); + expect(mock.readinessCalls).toContainEqual({ + target: "stack", + options: { mode: "inherit" }, + }); + }); + + test("preserves StackReadinessError across the daemon transport", async () => { + const failingMock = mockStack({ waitReadyTimeoutMs: 75 }); + const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); + let failingClient: ManagedRuntime.ManagedRuntime | undefined; + try { + const daemon = await failingServer.runPromise(DaemonServer); + const addr = daemon.address; + if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; + failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); + + const error = await failingClient.runPromise( + Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip), + ); + expect(error._tag).toBe("StackReadinessError"); + if (error._tag === "StackReadinessError") { + expect(error.target).toBe("auth"); + expect(error.timeoutMs).toBe(75); + } + } finally { + await failingClient?.dispose(); + await failingServer.dispose(); + } }); test("interrupting waitReady aborts the daemon request", async () => { @@ -417,13 +488,46 @@ describe("RemoteStack integration", () => { expect(mock.serviceCalls).toContain("restart:postgres"); }); + test("reloadFunctions transports the validated bundle in a JSON body", async () => { + await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => stack.reloadFunctions({ functions: functionsBundle })), + ); + + expect(mock.functionReloads).toEqual([{ functions: functionsBundle }]); + }); + + test("reloadFunctions returns a typed build error for an invalid bundle", async () => { + const invalidBundle = { + ...functionsBundle, + functions: [{ ...functionsBundle.functions[0]!, entrypointPath: "relative/index.ts" }], + }; + + const error = await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => + stack.reloadFunctions({ functions: invalidBundle }).pipe(Effect.flip), + ), + ); + + expect(error).toBeInstanceOf(StackBuildError); + expect(error._tag).toBe("StackBuildError"); + if (error._tag === "StackBuildError") { + expect(error.detail).toBe("Invalid Edge Functions reload payload"); + } + }); + test("reloadEdgeRuntime records the call", async () => { await clientRuntime.runPromise( Effect.flatMap(Stack, (stack) => - stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }), + stack.reloadEdgeRuntime({ + edgeRuntime: { policy: "oneshot" }, + functions: functionsBundle, + }), ), ); expect(mock.serviceCalls).toContain("reload-edge-runtime"); + expect(mock.edgeRuntimeReloads).toEqual([ + { edgeRuntime: { policy: "oneshot" }, functions: functionsBundle }, + ]); }); test("logHistory returns entries", async () => { diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index df7388559f..7bbd6fb72a 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -3,8 +3,9 @@ import { Effect, Layer, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { DaemonErrorResponseSchema } from "./DaemonProtocol.ts"; -import { StackBuildError } from "./errors.ts"; +import { StackBuildError, StackReadinessError } from "./errors.ts"; import { Stack, StackInfoSchema } from "./Stack.ts"; +import { inheritReadyOptions } from "./StackConfig.ts"; import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; import { SERVICE_NAMES } from "./versions.ts"; @@ -105,7 +106,10 @@ function withAbortSignal( const failDaemonResponse = ( response: HttpClientResponse.HttpClientResponse, fallbackName: string, -): Effect.Effect => +): Effect.Effect< + never, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError +> => Effect.gen(function* () { const body = yield* HttpClientResponse.schemaBodyJson(DaemonErrorResponseSchema)(response).pipe( Effect.orDie, @@ -121,13 +125,22 @@ const failDaemonResponse = ( }); case "STACK_BUILD_ERROR": return yield* new StackBuildError({ detail: body.error }); + case "STACK_READINESS_TIMEOUT": + return yield* new StackReadinessError({ + target: body.service ?? fallbackName, + timeoutMs: body.timeoutMs ?? 0, + detail: body.error, + }); } }); const expectDaemonOk = ( response: HttpClientResponse.HttpClientResponse, fallbackName: string, -): Effect.Effect => +): Effect.Effect< + void, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError +> => response.status >= 200 && response.status < 300 ? Effect.void : failDaemonResponse(response, fallbackName); @@ -301,6 +314,7 @@ export const RemoteStack = { }); yield* expectDaemonOk(response, name).pipe( Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)), + Effect.catchTag("StackReadinessError", (error) => Effect.die(error)), ); }), ), @@ -323,15 +337,11 @@ export const RemoteStack = { reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse( - socketPath, - `/functions/reload${encodeSearchParams({ - envFile: opts?.envFile, - noVerifyJwt: - opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), - })}`, - { method: "POST" }, - ); + const response = yield* unixResponse(socketPath, "/functions/reload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts ?? {}), + }); yield* expectDaemonOk(response, "edge-runtime"); }), ), @@ -392,7 +402,7 @@ export const RemoteStack = { }), ), - waitReady: (name: string) => + waitReady: (name, opts) => withUnixHttpClient( withAbortSignal((signal) => Effect.gen(function* () { @@ -400,18 +410,28 @@ export const RemoteStack = { const response = yield* unixResponse( socketPath, `/services/${servicePath}/ready`, - { signal }, + { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts ?? inheritReadyOptions), + }, ); yield* expectDaemonOk(response, name); }), ), ), - waitAllReady: () => + waitAllReady: (opts) => withUnixHttpClient( withAbortSignal((signal) => Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/ready", { signal }); + const response = yield* unixResponse(socketPath, "/ready", { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts ?? inheritReadyOptions), + }); yield* expectDaemonOk(response, "stack").pipe( Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), ); diff --git a/packages/stack/src/ServiceActivation.ts b/packages/stack/src/ServiceActivation.ts index 0edb4dafb9..27f055eae2 100644 --- a/packages/stack/src/ServiceActivation.ts +++ b/packages/stack/src/ServiceActivation.ts @@ -1,42 +1,13 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Layer } from "effect"; -import { StackBuildError, StackNotRunningError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import { stackServiceStartupBudgetSeconds } from "./services/health-budgets.ts"; -import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; - -export interface ServiceActivationPolicy { - /** Whether the public service must already be running when lazy startup completes. */ - readonly startup: "eager" | "lazy"; - /** Other public services required when this service is activated. */ - readonly activates?: ReadonlyArray; - /** Private companions whose lifecycle is exclusively owned by this service. */ - readonly owns?: ReadonlyArray; -} - -/** - * Central ownership map for lazy startup. Services with a direct TCP or HTTP - * endpoint must be running before that endpoint is published. Companion - * services are activated with the public service that consumes them. - */ -export const SERVICE_ACTIVATION_POLICY: Readonly> = { - postgres: { startup: "eager" }, - postgrest: { startup: "lazy" }, - auth: { startup: "lazy" }, - "edge-runtime": { startup: "lazy" }, - realtime: { startup: "eager" }, - storage: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] }, - imgproxy: { startup: "lazy" }, - mailpit: { startup: "eager" }, - pgmeta: { startup: "lazy" }, - studio: { startup: "eager", activates: ["analytics"] }, - analytics: { startup: "lazy", activates: ["vector"], owns: ["vector"] }, - vector: { startup: "lazy" }, - pooler: { startup: "eager" }, -}; +import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; export const eagerServices = (enabled: ReadonlyArray): ReadonlyArray => - enabled.filter((service) => SERVICE_ACTIVATION_POLICY[service].startup === "eager"); + enabled.filter((service) => serviceMetadata(service).activation.startup === "eager"); export const activationTargetsForService = ( enabledServices: ReadonlyArray, @@ -46,7 +17,7 @@ export const activationTargetsForService = ( const targets = new Set(); const addWithCompanions = (target: ServiceName): void => { if (!enabled.has(target) || targets.has(target)) return; - for (const activated of SERVICE_ACTIVATION_POLICY[target].activates ?? []) { + for (const activated of serviceMetadata(target).activation.activates) { addWithCompanions(activated); } targets.add(target); @@ -88,7 +59,7 @@ export const lifecycleTargetsForService = ( const addWithOwnedCompanions = (target: ServiceName): void => { add(target); - for (const owned of SERVICE_ACTIVATION_POLICY[target].owns ?? []) { + for (const owned of serviceMetadata(target).activation.owns) { addWithOwnedCompanions(owned); } }; @@ -103,7 +74,11 @@ export class StackServiceActivator extends Context.Service< service: ServiceName, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackNotRunningError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; } >()("stack/StackServiceActivator") { diff --git a/packages/stack/src/ServiceActivation.unit.test.ts b/packages/stack/src/ServiceActivation.unit.test.ts index 83336d573d..7b8a09de96 100644 --- a/packages/stack/src/ServiceActivation.unit.test.ts +++ b/packages/stack/src/ServiceActivation.unit.test.ts @@ -4,13 +4,12 @@ import { activationTargetsForService, eagerServices, lifecycleTargetsForService, - SERVICE_ACTIVATION_POLICY, } from "./ServiceActivation.ts"; -import { SERVICE_NAMES } from "./versions.ts"; +import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; describe("service activation", () => { it("defines an access policy for every stack service", () => { - expect(Object.keys(SERVICE_ACTIVATION_POLICY).sort()).toEqual([...SERVICE_NAMES].sort()); + expect(Object.keys(SERVICE_CATALOG).sort()).toEqual([...SERVICE_NAMES].sort()); }); it("starts direct endpoints eagerly", () => { diff --git a/packages/stack/src/ServiceArtifacts.ts b/packages/stack/src/ServiceArtifacts.ts deleted file mode 100644 index 65921365c7..0000000000 --- a/packages/stack/src/ServiceArtifacts.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - authAssetName, - edgeRuntimeAssetName, - postgresAssetName, - postgrestAssetName, - type PlatformInfo, -} from "./Platform.ts"; -import type { ServiceName } from "./versions.ts"; - -type ArtifactOwnership = "supabase" | "upstream"; -type ServiceRuntimeSupport = "native-preferred" | "docker-only"; -export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip"; - -export interface NativeReleaseArtifact { - readonly provider: string; - readonly assetName: string; - readonly archive: ArchiveFormat; - readonly downloadUrl: string; - readonly checksumUrl: string | null; - readonly stripComponents: boolean; -} - -interface NativeReleaseSource { - readonly provider: string; - readonly resolve: (version: string, platform: PlatformInfo) => NativeReleaseArtifact | undefined; -} - -interface DockerImageSource { - readonly ownership: ArtifactOwnership; - readonly repository: string; - readonly tagPrefix?: string; -} - -export interface ServiceArtifactDefinition { - readonly runtimeSupport: ServiceRuntimeSupport; - readonly docker: DockerImageSource; - readonly native?: NativeReleaseSource; -} - -const SUPABASE_ECR_REGISTRY = "public.ecr.aws/supabase"; -const SUPABASE_DOCKER_HUB_REGISTRY = "supabase"; -const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase"; - -const nativeRelease = ( - provider: string, - assetName: string | null, - archive: ArchiveFormat, - downloadUrl: string, - options?: { - readonly checksumUrl?: string; - readonly stripComponents?: boolean; - }, -): NativeReleaseArtifact | undefined => - assetName === null - ? undefined - : { - provider, - assetName, - archive, - downloadUrl, - checksumUrl: options?.checksumUrl ?? null, - stripComponents: options?.stripComponents ?? false, - }; - -const authReleaseTag = (version: string): string => - version.includes("-rc.") ? `rc${version}` : `v${version}`; - -export const SERVICE_ARTIFACTS: Record = { - postgres: { - runtimeSupport: "native-preferred", - docker: { ownership: "supabase", repository: "postgres" }, - native: { - provider: "github.com/supabase/postgres", - resolve: (version, platform) => { - const assetName = postgresAssetName(platform); - const cliVersion = `${version}-cli`; - const url = `https://github.com/supabase/postgres/releases/download/v${cliVersion}/supabase-postgres-v${cliVersion}-${assetName}.tar.gz`; - return nativeRelease("github.com/supabase/postgres", assetName, "tar.gz", url, { - checksumUrl: `${url}.sha256`, - stripComponents: true, - }); - }, - }, - }, - postgrest: { - runtimeSupport: "native-preferred", - docker: { ownership: "supabase", repository: "postgrest", tagPrefix: "v" }, - native: { - provider: "github.com/PostgREST/postgrest", - resolve: (version, platform) => { - const assetName = postgrestAssetName(platform); - const archive = assetName?.startsWith("windows") === true ? "zip" : "tar.xz"; - return nativeRelease( - "github.com/PostgREST/postgrest", - assetName, - archive, - `https://github.com/PostgREST/postgrest/releases/download/v${version}/postgrest-v${version}-${assetName}.${archive}`, - ); - }, - }, - }, - auth: { - runtimeSupport: "native-preferred", - docker: { ownership: "supabase", repository: "gotrue", tagPrefix: "v" }, - native: { - provider: "github.com/supabase/auth", - resolve: (version, platform) => { - const assetName = authAssetName(platform); - return nativeRelease( - "github.com/supabase/auth", - assetName, - "tar.gz", - `https://github.com/supabase/auth/releases/download/${authReleaseTag(version)}/auth-v${version}-${assetName}.tar.gz`, - ); - }, - }, - }, - "edge-runtime": { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "edge-runtime", tagPrefix: "v" }, - native: { - provider: "github.com/supabase/edge-runtime", - resolve: (version, platform) => { - const assetName = edgeRuntimeAssetName(platform); - return nativeRelease( - "github.com/supabase/edge-runtime", - assetName, - "tar.gz", - `https://github.com/supabase/edge-runtime/releases/download/v${version}/edge-runtime-v${version}-${assetName}.tar.gz`, - ); - }, - }, - }, - realtime: { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" }, - }, - storage: { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" }, - }, - imgproxy: { - runtimeSupport: "docker-only", - docker: { ownership: "upstream", repository: "darthsim/imgproxy" }, - }, - mailpit: { - runtimeSupport: "docker-only", - docker: { ownership: "upstream", repository: "axllent/mailpit" }, - }, - pgmeta: { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "postgres-meta", tagPrefix: "v" }, - }, - studio: { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "studio" }, - }, - analytics: { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "logflare" }, - }, - vector: { - runtimeSupport: "docker-only", - docker: { ownership: "upstream", repository: "timberio/vector" }, - }, - pooler: { - runtimeSupport: "docker-only", - docker: { ownership: "supabase", repository: "supavisor" }, - }, -}; - -export const nativeReleaseForService = ( - service: ServiceName, - version: string, - platform: PlatformInfo, -): NativeReleaseArtifact | undefined => - SERVICE_ARTIFACTS[service].native?.resolve(version, platform); - -export const isDockerOnlyService = (service: ServiceName): boolean => - SERVICE_ARTIFACTS[service].runtimeSupport === "docker-only"; - -const dockerTag = (service: ServiceName, version: string): string => { - const source = SERVICE_ARTIFACTS[service].docker; - return `${source.tagPrefix ?? ""}${version}`; -}; - -export const dockerImageForArtifact = (service: ServiceName, version: string): string => { - const source = SERVICE_ARTIFACTS[service].docker; - const repository = - source.ownership === "supabase" - ? `${SUPABASE_ECR_REGISTRY}/${source.repository}` - : source.repository; - return `${repository}:${dockerTag(service, version)}`; -}; - -export const dockerImageCandidatesForArtifact = ( - service: ServiceName, - version: string, -): ReadonlyArray => { - const source = SERVICE_ARTIFACTS[service].docker; - const tag = dockerTag(service, version); - if (source.ownership === "upstream") { - return [`${source.repository}:${tag}`]; - } - return [ - `${SUPABASE_ECR_REGISTRY}/${source.repository}:${tag}`, - `${SUPABASE_DOCKER_HUB_REGISTRY}/${source.repository}:${tag}`, - `${SUPABASE_GHCR_REGISTRY}/${source.repository}:${tag}`, - ]; -}; - -export const imageTagPrefixForService = (service: ServiceName): string | undefined => - SERVICE_ARTIFACTS[service].docker.tagPrefix; diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts new file mode 100644 index 0000000000..aa004a98cf --- /dev/null +++ b/packages/stack/src/ServiceCatalog.ts @@ -0,0 +1,337 @@ +import { Record } from "effect"; +import { + authAssetName, + edgeRuntimeAssetName, + postgresAssetName, + postgrestAssetName, + type PlatformInfo, +} from "./Platform.ts"; +import type { PortField } from "./PortAllocator.ts"; +import type { ServiceName } from "./ServiceName.ts"; + +type ArtifactOwnership = "supabase" | "upstream"; +type ServiceRuntimeSupport = "native-preferred" | "docker-only"; +export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip"; + +export interface NativeReleaseArtifact { + readonly provider: string; + readonly assetName: string; + readonly archive: ArchiveFormat; + readonly downloadUrl: string; + readonly checksumUrl: string | null; + readonly stripComponents: boolean; +} + +interface NativeReleaseSource { + readonly provider: string; + readonly resolve: (version: string, platform: PlatformInfo) => NativeReleaseArtifact | undefined; +} + +interface DockerImageSource { + readonly ownership: ArtifactOwnership; + readonly repository: string; + readonly tagPrefix?: string; +} + +interface ServiceArtifactDefinition { + readonly docker: DockerImageSource; + readonly native?: NativeReleaseSource; +} + +interface ServiceActivationPolicy { + /** Whether the public service must already be running when lazy startup completes. */ + readonly startup: "eager" | "lazy"; + /** Other public services required when this service is activated. */ + readonly activates: ReadonlyArray; + /** Private companions whose lifecycle is exclusively owned by this service. */ + readonly owns: ReadonlyArray; +} + +type ServiceConfigKey = + | "postgres" + | "postgrest" + | "auth" + | "edgeRuntime" + | "realtime" + | "storage" + | "imgproxy" + | "mailpit" + | "pgmeta" + | "studio" + | "analytics" + | "vector" + | "pooler"; + +export interface ServiceCatalogEntry { + readonly name: Name; + readonly configKey: ServiceConfigKey; + readonly defaultVersion: string; + readonly runtimeSupport: ServiceRuntimeSupport; + readonly artifact: ServiceArtifactDefinition; + readonly activation: ServiceActivationPolicy; + readonly portFields: ReadonlyArray; +} + +const SUPABASE_ECR_REGISTRY = "public.ecr.aws/supabase"; +const SUPABASE_DOCKER_HUB_REGISTRY = "supabase"; +const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase"; + +const nativeRelease = ( + provider: string, + assetName: string | null, + archive: ArchiveFormat, + downloadUrl: string, + options?: { + readonly checksumUrl?: string; + readonly stripComponents?: boolean; + }, +): NativeReleaseArtifact | undefined => + assetName === null + ? undefined + : { + provider, + assetName, + archive, + downloadUrl, + checksumUrl: options?.checksumUrl ?? null, + stripComponents: options?.stripComponents ?? false, + }; + +const authReleaseTag = (version: string): string => + version.includes("-rc.") ? `rc${version}` : `v${version}`; + +/** + * Exhaustive static identity and capability metadata for public stack services. + * Cross-service topology and process definitions deliberately remain in StackBuilder. + */ +export const SERVICE_CATALOG = { + postgres: { + name: "postgres", + configKey: "postgres", + defaultVersion: "17.6.1.158", + runtimeSupport: "native-preferred", + artifact: { + docker: { ownership: "supabase", repository: "postgres" }, + native: { + provider: "github.com/supabase/postgres", + resolve: (version, platform) => { + const assetName = postgresAssetName(platform); + const cliVersion = `${version}-cli`; + const url = `https://github.com/supabase/postgres/releases/download/v${cliVersion}/supabase-postgres-v${cliVersion}-${assetName}.tar.gz`; + return nativeRelease("github.com/supabase/postgres", assetName, "tar.gz", url, { + checksumUrl: `${url}.sha256`, + stripComponents: true, + }); + }, + }, + }, + activation: { startup: "eager", activates: [], owns: [] }, + portFields: ["dbPort"], + }, + postgrest: { + name: "postgrest", + configKey: "postgrest", + defaultVersion: "14.16", + runtimeSupport: "native-preferred", + artifact: { + docker: { ownership: "supabase", repository: "postgrest", tagPrefix: "v" }, + native: { + provider: "github.com/PostgREST/postgrest", + resolve: (version, platform) => { + const assetName = postgrestAssetName(platform); + const archive = assetName?.startsWith("windows") === true ? "zip" : "tar.xz"; + return nativeRelease( + "github.com/PostgREST/postgrest", + assetName, + archive, + `https://github.com/PostgREST/postgrest/releases/download/v${version}/postgrest-v${version}-${assetName}.${archive}`, + ); + }, + }, + }, + activation: { startup: "lazy", activates: [], owns: [] }, + portFields: ["postgrestPort", "postgrestAdminPort"], + }, + auth: { + name: "auth", + configKey: "auth", + defaultVersion: "2.195.0", + runtimeSupport: "native-preferred", + artifact: { + docker: { ownership: "supabase", repository: "gotrue", tagPrefix: "v" }, + native: { + provider: "github.com/supabase/auth", + resolve: (version, platform) => { + const assetName = authAssetName(platform); + return nativeRelease( + "github.com/supabase/auth", + assetName, + "tar.gz", + `https://github.com/supabase/auth/releases/download/${authReleaseTag(version)}/auth-v${version}-${assetName}.tar.gz`, + ); + }, + }, + }, + activation: { startup: "lazy", activates: [], owns: [] }, + portFields: ["authPort"], + }, + "edge-runtime": { + name: "edge-runtime", + configKey: "edgeRuntime", + defaultVersion: "1.74.3", + runtimeSupport: "docker-only", + artifact: { + docker: { ownership: "supabase", repository: "edge-runtime", tagPrefix: "v" }, + native: { + provider: "github.com/supabase/edge-runtime", + resolve: (version, platform) => { + const assetName = edgeRuntimeAssetName(platform); + return nativeRelease( + "github.com/supabase/edge-runtime", + assetName, + "tar.gz", + `https://github.com/supabase/edge-runtime/releases/download/v${version}/edge-runtime-v${version}-${assetName}.tar.gz`, + ); + }, + }, + }, + activation: { startup: "lazy", activates: [], owns: [] }, + portFields: ["edgeRuntimePort", "edgeRuntimeInspectorPort"], + }, + realtime: { + name: "realtime", + configKey: "realtime", + defaultVersion: "2.123.1", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" } }, + activation: { startup: "eager", activates: [], owns: [] }, + portFields: ["realtimePort"], + }, + storage: { + name: "storage", + configKey: "storage", + defaultVersion: "1.68.1", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" } }, + activation: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] }, + portFields: ["storagePort"], + }, + imgproxy: { + name: "imgproxy", + configKey: "imgproxy", + defaultVersion: "v3.8.0", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "upstream", repository: "darthsim/imgproxy" } }, + activation: { startup: "lazy", activates: [], owns: [] }, + portFields: ["imgproxyPort"], + }, + mailpit: { + name: "mailpit", + configKey: "mailpit", + defaultVersion: "v1.30.2", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "upstream", repository: "axllent/mailpit" } }, + activation: { startup: "eager", activates: [], owns: [] }, + portFields: ["mailpitPort", "mailpitSmtpPort", "mailpitPop3Port"], + }, + pgmeta: { + name: "pgmeta", + configKey: "pgmeta", + defaultVersion: "0.96.6", + runtimeSupport: "docker-only", + artifact: { + docker: { ownership: "supabase", repository: "postgres-meta", tagPrefix: "v" }, + }, + activation: { startup: "lazy", activates: [], owns: [] }, + portFields: ["pgmetaPort"], + }, + studio: { + name: "studio", + configKey: "studio", + defaultVersion: "2026.08.03-sha-022b374", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "supabase", repository: "studio" } }, + activation: { startup: "eager", activates: ["analytics"], owns: [] }, + portFields: ["studioPort"], + }, + analytics: { + name: "analytics", + configKey: "analytics", + defaultVersion: "1.49.2", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "supabase", repository: "logflare" } }, + activation: { startup: "lazy", activates: ["vector"], owns: ["vector"] }, + portFields: ["analyticsPort"], + }, + vector: { + name: "vector", + configKey: "vector", + defaultVersion: "0.53.0-alpine", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "upstream", repository: "timberio/vector" } }, + activation: { startup: "lazy", activates: [], owns: [] }, + portFields: [], + }, + pooler: { + name: "pooler", + configKey: "pooler", + defaultVersion: "2.9.7", + runtimeSupport: "docker-only", + artifact: { docker: { ownership: "supabase", repository: "supavisor" } }, + activation: { startup: "eager", activates: [], owns: [] }, + portFields: ["poolerPort", "poolerApiPort"], + }, +} satisfies { readonly [Name in ServiceName]: ServiceCatalogEntry }; + +export const SERVICE_NAMES: ReadonlyArray = Record.keys(SERVICE_CATALOG); + +export const DEFAULT_VERSIONS: Readonly> = Record.map( + SERVICE_CATALOG, + (metadata) => metadata.defaultVersion, +); + +export const serviceMetadata = (service: ServiceName): ServiceCatalogEntry => + SERVICE_CATALOG[service]; + +export const nativeReleaseForService = ( + service: ServiceName, + version: string, + platform: PlatformInfo, +): NativeReleaseArtifact | undefined => + serviceMetadata(service).artifact.native?.resolve(version, platform); + +export const isDockerOnlyService = (service: ServiceName): boolean => + SERVICE_CATALOG[service].runtimeSupport === "docker-only"; + +const dockerTag = (service: ServiceName, version: string): string => { + const source = serviceMetadata(service).artifact.docker; + return `${source.tagPrefix ?? ""}${version}`; +}; + +export const dockerImageForArtifact = (service: ServiceName, version: string): string => { + const source = SERVICE_CATALOG[service].artifact.docker; + const repository = + source.ownership === "supabase" + ? `${SUPABASE_ECR_REGISTRY}/${source.repository}` + : source.repository; + return `${repository}:${dockerTag(service, version)}`; +}; + +export const dockerImageCandidatesForArtifact = ( + service: ServiceName, + version: string, +): ReadonlyArray => { + const source = SERVICE_CATALOG[service].artifact.docker; + const tag = dockerTag(service, version); + if (source.ownership === "upstream") { + return [`${source.repository}:${tag}`]; + } + return [ + `${SUPABASE_ECR_REGISTRY}/${source.repository}:${tag}`, + `${SUPABASE_DOCKER_HUB_REGISTRY}/${source.repository}:${tag}`, + `${SUPABASE_GHCR_REGISTRY}/${source.repository}:${tag}`, + ]; +}; + +export const imageTagPrefixForService = (service: ServiceName): string | undefined => + serviceMetadata(service).artifact.docker.tagPrefix; diff --git a/packages/stack/src/ServiceCatalog.unit.test.ts b/packages/stack/src/ServiceCatalog.unit.test.ts new file mode 100644 index 0000000000..d00f5a177b --- /dev/null +++ b/packages/stack/src/ServiceCatalog.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_VERSIONS, SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; + +describe("ServiceCatalog", () => { + it("derives exhaustive iteration and defaults from catalog entries", () => { + expect(SERVICE_NAMES).toEqual(Object.keys(SERVICE_CATALOG)); + expect(Object.keys(DEFAULT_VERSIONS)).toEqual(SERVICE_NAMES); + + for (const service of SERVICE_NAMES) { + expect(SERVICE_CATALOG[service].name).toBe(service); + expect(DEFAULT_VERSIONS[service]).toBe(SERVICE_CATALOG[service].defaultVersion); + } + }); + + it("references only catalog services in activation relationships", () => { + const knownServices = new Set(SERVICE_NAMES); + for (const service of SERVICE_NAMES) { + const { activates, owns } = SERVICE_CATALOG[service].activation; + expect([...activates, ...owns].every((related) => knownServices.has(related))).toBe(true); + } + }); +}); diff --git a/packages/stack/src/ServiceName.ts b/packages/stack/src/ServiceName.ts new file mode 100644 index 0000000000..f9524cacd2 --- /dev/null +++ b/packages/stack/src/ServiceName.ts @@ -0,0 +1,15 @@ +/** Public Supabase services represented in a local stack. */ +export type ServiceName = + | "postgres" + | "postgrest" + | "auth" + | "edge-runtime" + | "realtime" + | "storage" + | "imgproxy" + | "mailpit" + | "pgmeta" + | "studio" + | "analytics" + | "vector" + | "pooler"; diff --git a/packages/stack/src/ServicePorts.ts b/packages/stack/src/ServicePorts.ts index 27a0b125c0..cb61925149 100644 --- a/packages/stack/src/ServicePorts.ts +++ b/packages/stack/src/ServicePorts.ts @@ -1,31 +1,16 @@ import type { PortField } from "./PortAllocator.ts"; -import { enabledServicesForConfig, type ResolvedStackConfig } from "./StackBuilder.ts"; -import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; +import { enabledServicesForConfig } from "./StackBuilder.ts"; +import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; export const allocatedPortFieldsForConfig = ( config: ResolvedStackConfig, ): ReadonlyArray => [ "apiPort", - ...enabledServicesForConfig(config).flatMap((service) => SERVICE_PORT_FIELDS[service]), + ...enabledServicesForConfig(config).flatMap((service) => SERVICE_CATALOG[service].portFields), ]; -const SERVICE_PORT_FIELDS = { - postgres: ["dbPort"], - postgrest: ["postgrestPort", "postgrestAdminPort"], - auth: ["authPort"], - "edge-runtime": ["edgeRuntimePort", "edgeRuntimeInspectorPort"], - realtime: ["realtimePort"], - storage: ["storagePort"], - imgproxy: ["imgproxyPort"], - mailpit: ["mailpitPort", "mailpitSmtpPort", "mailpitPop3Port"], - pgmeta: ["pgmetaPort"], - studio: ["studioPort"], - analytics: ["analyticsPort"], - vector: [], - pooler: ["poolerPort", "poolerApiPort"], -} as const satisfies Readonly>>; - export const portFieldsForService = (name: string): ReadonlyArray => { const service = SERVICE_NAMES.find((candidate) => candidate === name); - return service === undefined ? [] : SERVICE_PORT_FIELDS[service]; + return service === undefined ? [] : SERVICE_CATALOG[service].portFields; }; diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 06ab6abd44..9693b08d02 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -1,10 +1,13 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; -import { Effect, Layer, Schema, Context, Stream } from "effect"; -import { StackBuildError } from "./errors.ts"; -import type { FunctionsConfig } from "./functions.ts"; -import { StackLifecycleCoordinator } from "./StackLifecycleCoordinator.ts"; -import type { EdgeRuntimeConfig, ResolvedStackConfig } from "./StackBuilder.ts"; +import { Context, Effect, Schema, Stream } from "effect"; +import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { + ResolvedFunctionsBundleSchema, + type FunctionsReloadConfig, + type ResolvedFunctionsBundle, +} from "./functions.ts"; +import type { EdgeRuntimeConfig, ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; export interface StackInfo { @@ -34,45 +37,53 @@ const EdgeRuntimeConfigSchema = Schema.Struct({ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); -const FunctionsConfigSchema = Schema.Struct({ - envFile: Schema.optionalKey(Schema.String), - noVerifyJwt: Schema.optionalKey(Schema.Boolean), -}); - export const EdgeRuntimeReloadConfigSchema = Schema.Struct({ edgeRuntime: EdgeRuntimeConfigSchema, - functions: Schema.optionalKey(FunctionsConfigSchema), + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), }); export interface EdgeRuntimeReloadConfig { readonly edgeRuntime: EdgeRuntimeConfig; - readonly functions?: FunctionsConfig; + readonly functions?: ResolvedFunctionsBundle; } -type StackService = typeof Stack.Service; - export class Stack extends Context.Service< Stack, { readonly getInfo: () => Effect.Effect; - readonly start: () => Effect.Effect; + readonly start: () => Effect.Effect< + void, + ServiceReadyError | StackBuildError | StackReadinessError + >; readonly stop: () => Effect.Effect; readonly dispose: () => Effect.Effect; readonly startService: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + >; readonly stopService: ( name: string, ) => Effect.Effect; readonly restartService: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + >; readonly reloadFunctions: ( - opts?: FunctionsConfig, - ) => Effect.Effect; + opts?: FunctionsReloadConfig, + ) => Effect.Effect< + void, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + >; readonly reloadEdgeRuntime: ( opts: EdgeRuntimeReloadConfig, - ) => Effect.Effect; + ) => Effect.Effect< + void, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + >; readonly getState: (name: string) => Effect.Effect; readonly getAllStates: () => Effect.Effect>; readonly stateChanges: ( @@ -81,8 +92,14 @@ export class Stack extends Context.Service< readonly allStateChanges: () => Stream.Stream; readonly waitReady: ( name: string, - ) => Effect.Effect; - readonly waitAllReady: () => Effect.Effect; + opts?: ReadyOptions, + ) => Effect.Effect< + void, + ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + >; + readonly waitAllReady: ( + opts?: ReadyOptions, + ) => Effect.Effect; readonly subscribeLogs: (name: string) => Stream.Stream; readonly subscribeAllLogs: (services?: ReadonlyArray) => Stream.Stream; readonly logHistory: (name: string, limit?: number) => Effect.Effect>; @@ -91,35 +108,4 @@ export class Stack extends Context.Service< services?: ReadonlyArray, ) => Effect.Effect>; } ->()("stack/Stack") { - static layer = ( - _config: ResolvedStackConfig, - ): Layer.Layer => - Layer.effect( - this, - Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - return { - getInfo: coordinator.getInfo, - start: coordinator.start, - stop: coordinator.stop, - dispose: coordinator.dispose, - startService: coordinator.startService, - stopService: coordinator.stopService, - restartService: coordinator.restartService, - reloadFunctions: coordinator.reloadFunctions, - reloadEdgeRuntime: coordinator.reloadEdgeRuntime, - getState: coordinator.getState, - getAllStates: coordinator.getAllStates, - stateChanges: coordinator.stateChanges, - allStateChanges: coordinator.allStateChanges, - waitReady: coordinator.waitReady, - waitAllReady: coordinator.waitAllReady, - subscribeLogs: coordinator.subscribeLogs, - subscribeAllLogs: coordinator.subscribeAllLogs, - logHistory: coordinator.logHistory, - logHistoryAll: coordinator.logHistoryAll, - } satisfies StackService; - }), - ); -} +>()("stack/Stack") {} diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 7a959e37bc..e3aa046ea5 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -1,17 +1,25 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { chmod, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; +import { StackBuildError } from "./errors.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { functionsRuntimeConfigPath, type ResolvedFunctionsBundle } from "./functions.ts"; import type { AllocatedPorts, PortField, PortLease } from "./PortAllocator.ts"; +import { StackServiceActivator } from "./ServiceActivation.ts"; import { Stack } from "./Stack.ts"; -import { StackLifecycleCoordinator } from "./StackLifecycleCoordinator.ts"; +import { localStackLayer } from "./LocalStack.ts"; import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; import { StackPreparation } from "./StackPreparation.ts"; import { StackBuilder } from "./StackBuilder.ts"; -import type { ResolvedStackConfig } from "./StackBuilder.ts"; +import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; @@ -44,6 +52,7 @@ const defaultConfig: ResolvedStackConfig = { projectDir: "/tmp/supabase-project", mode: "native", startupMode: "eager", + readiness: DEFAULT_STACK_READINESS_POLICY, jwtSecret: testJwtSecret, ports: defaultPorts, apiPort: 54321, @@ -100,6 +109,20 @@ const edgeRuntimeConfig: ResolvedStackConfig = { }, }; +const functionsBundle = (root: string, value: string): ResolvedFunctionsBundle => ({ + env: { SHARED: value }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: join(root, "hello", "index.ts"), + importMapPath: null, + staticFiles: [], + env: { FUNCTION_VALUE: value }, + }, + ], +}); + const noopPortLease = (ports: AllocatedPorts): PortLease => ({ ports, reserve: () => Effect.void, @@ -114,7 +137,7 @@ function setupLayer( ) { const resolver = mockBinaryResolver(); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const coordinatorLayer = StackLifecycleCoordinator.layer(config, portLease).pipe( + const layer = localStackLayer(config, portLease).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(StackMetadataPersistence.noop), @@ -122,9 +145,7 @@ function setupLayer( Layer.provide(BunServices.layer), ); - const layer = Stack.layer(config).pipe(Layer.provide(coordinatorLayer)); - - return { coordinatorLayer, layer, resolver, spawner }; + return { layer, resolver, spawner }; } describe("Stack", () => { @@ -154,6 +175,100 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); + it.live("preserves the current functions bundle across repeated runtime reloads", () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-")); + const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); + const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); + const config = { + ...edgeRuntimeConfig, + projectDir: runtimeRoot, + runtimeRoot, + functions: initialBundle, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { + name: "edge-runtime", + command: process.execPath, + restart: "unless-stopped", + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([["edge-runtime", { visibility: "public" as const }]]), + }), + }); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(BunServices.layer), + ); + const readRuntimeConfig = Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => + JSON.parse(contents), + ), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + yield* stack.reloadFunctions({ functions: replacementBundle }); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.reloadFunctions(); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + const duplicateBundle = { + ...replacementBundle, + functions: [replacementBundle.functions[0]!, replacementBundle.functions[0]!], + }; + expect( + (yield* stack.reloadFunctions({ functions: duplicateBundle }).pipe(Effect.flip))._tag, + ).toBe("StackBuildError"); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + const runtimeDirectory = join(runtimeRoot, "edge-runtime"); + yield* Effect.promise(() => chmod(runtimeDirectory, 0o500)); + const failedBundle = functionsBundle(runtimeRoot, "failed-secret"); + const error = yield* stack.reloadFunctions({ functions: failedBundle }).pipe(Effect.flip); + expect(error._tag).toBe("StackBuildError"); + + yield* Effect.promise(() => chmod(runtimeDirectory, 0o700)); + yield* stack.reloadFunctions(); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.dispose(); + expect( + yield* Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then( + () => true, + () => false, + ), + ), + ).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.promise(async () => { + await chmod(join(runtimeRoot, "edge-runtime"), 0o700).catch(() => {}); + await rm(runtimeRoot, { recursive: true, force: true }); + }), + ), + Effect.timeout("5 seconds"), + ); + }); + it.effect("getInfo returns valid JWT tokens", () => { const { layer } = setupLayer(); @@ -305,16 +420,12 @@ describe("Stack", () => { }); const spawner = mockChildProcessSpawner(); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const coordinatorLayer = StackLifecycleCoordinator.layer( - defaultConfig, - noopPortLease(defaultConfig.ports), - ).pipe( + const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(StackMetadataPersistence.noop), ); - const layer = Stack.layer(defaultConfig).pipe( - Layer.provide(coordinatorLayer), + const providedLayer = layer.pipe( Layer.provide(spawner.layer), Layer.provide(BunServices.layer), ); @@ -333,7 +444,38 @@ describe("Stack", () => { yield* Fiber.interrupt(startFiber); expect(states.map((state) => state.status)).toContain("Downloading"); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(providedLayer)); + }); + + it.live("starts the readiness deadline after artifact preparation", () => { + const resolver = mockBinaryResolver({ + downloadedServices: ["postgres"], + downloadDelayMs: 1_000, + }); + const spawner = mockChildProcessSpawner(); + const config = { + ...defaultConfig, + postgrest: false, + auth: false, + readiness: { mode: "finite", timeoutMs: 250 }, + } satisfies ResolvedStackConfig; + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(StackBuilder.layer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(spawner.layer), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + const startedAt = Date.now(); + const exit = yield* stack.start().pipe(Effect.exit); + + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(900); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer), Effect.scoped, Effect.timeout("5 seconds")); }); it.effect("getState fails for internal helper services", () => { @@ -390,16 +532,12 @@ describe("Stack", () => { const resolver = mockBinaryResolver({ failServices: ["postgres", "postgrest", "auth"] }); const spawner = mockChildProcessSpawner({ exitCode: 1 }); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const coordinatorLayer = StackLifecycleCoordinator.layer( - defaultConfig, - noopPortLease(defaultConfig.ports), - ).pipe( + const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(StackMetadataPersistence.noop), ); - const layer = Stack.layer(defaultConfig).pipe( - Layer.provide(coordinatorLayer), + const providedLayer = layer.pipe( Layer.provide(spawner.layer), Layer.provide(BunServices.layer), ); @@ -412,7 +550,109 @@ describe("Stack", () => { // No container was ever started: only prepare-phase docker commands ran. const startedContainers = spawner.spawned.filter((record) => record.args[0] === "run"); expect(startedContainers).toEqual([]); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(providedLayer)); + }); + + it.live("can retry start after a build failure before services start", () => { + let buildAttempts = 0; + const graph = Effect.runSync( + buildGraph([{ name: "postgres", command: "true", restart: "no" }]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.suspend(() => { + buildAttempts += 1; + return buildAttempts === 1 + ? Effect.fail(new StackBuildError({ detail: "transient build failure" })) + : Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" }], + ]), + }); + }), + }); + const resolver = mockBinaryResolver(); + const spawner = mockChildProcessSpawner(); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(spawner.layer), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + + expect(Exit.isFailure(yield* stack.start().pipe(Effect.exit))).toBe(true); + yield* stack.start(); + + expect(buildAttempts).toBe(2); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("a partial startup failure disposes resources from services already started", () => { + let cleaned = false; + const spawner = mockChildProcessSpawner({ + beforeSpawn: (record) => + record.command === "fail" ? Effect.die("simulated spawn failure") : Effect.void, + }); + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "no", + cleanup: Effect.sync(() => { + cleaned = true; + }), + }, + { + name: "postgrest", + command: "fail", + dependencies: [{ service: "postgres", condition: "started" }], + restart: "no", + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" }], + ["postgrest", { visibility: "public" }], + ]), + }), + }); + const resolver = mockBinaryResolver(); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer( + { + ...defaultConfig, + readiness: { mode: "finite", timeoutMs: 1_000 }, + }, + noopPortLease(defaultConfig.ports), + ).pipe( + Layer.provide(builderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(spawner.layer), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + const exit = yield* stack.start().pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(cleaned).toBe(true); + expect(spawner.killed).toContain("SIGTERM"); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); it.live("lazy startup starts direct services without starting HTTP backends", () => { @@ -454,21 +694,22 @@ describe("Stack", () => { version: DEFAULT_VERSIONS.imgproxy, }, }; - const { coordinatorLayer } = setupLayer(config); + const { layer } = setupLayer(config); return Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - yield* coordinator.stopService("imgproxy"); + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + yield* stack.stopService("imgproxy"); - const error = yield* coordinator.activateService("storage").pipe(Effect.flip); + const error = yield* activator.activate("storage").pipe(Effect.flip); expect(error._tag).toBe("StackBuildError"); if (error._tag === "StackBuildError") { expect(error.detail).toContain("imgproxy was explicitly stopped"); } - yield* coordinator.stop(); - }).pipe(Effect.provide(coordinatorLayer), Effect.timeout("5 seconds")); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); it.live("lazy readiness includes an activation that is still starting", () => @@ -483,34 +724,35 @@ describe("Stack", () => { : Effect.void, }); const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; - const { coordinatorLayer } = setupLayer(config, noopPortLease(config.ports), spawner); + const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - expect((yield* coordinator.getState("auth")).status).toBe("Dormant"); - const activeStateFiber = yield* coordinator.allStateChanges().pipe( + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); + const activeStateFiber = yield* stack.allStateChanges().pipe( Stream.filter((state) => state.name === "auth" && state.status !== "Dormant"), Stream.runHead, Effect.forkChild({ startImmediately: true }), ); - const activationFiber = yield* coordinator - .activateService("auth") + const activationFiber = yield* activator + .activate("auth") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(spawnStarted); expect((yield* Fiber.join(activeStateFiber))._tag).toBe("Some"); - expect((yield* coordinator.getState("auth")).status).not.toBe("Dormant"); + expect((yield* stack.getState("auth")).status).not.toBe("Dormant"); - const readyFiber = yield* coordinator + const readyFiber = yield* stack .waitAllReady() .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; expect(readyFiber.pollUnsafe()).toBeUndefined(); - yield* coordinator.stop().pipe(Effect.timeout("1 second")); + yield* stack.stop().pipe(Effect.timeout("1 second")); yield* Fiber.interrupt(readyFiber); yield* Fiber.interrupt(activationFiber); - }).pipe(Effect.provide(coordinatorLayer)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); @@ -528,25 +770,26 @@ describe("Stack", () => { : Effect.void, }); const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; - const { coordinatorLayer } = setupLayer(config, noopPortLease(config.ports), spawner); + const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - const manualStart = yield* coordinator + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + const manualStart = yield* stack .startService("auth") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(authSpawnStarted); const activationCompleted = yield* Effect.race( - coordinator.activateService("postgres").pipe(Effect.as(true)), + activator.activate("postgres").pipe(Effect.as(true)), Effect.sleep("200 millis").pipe(Effect.as(false)), ); yield* Fiber.interrupt(manualStart); expect(activationCompleted).toBe(true); - yield* coordinator.stop(); - }).pipe(Effect.provide(coordinatorLayer)); + yield* stack.stop(); + }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); @@ -581,13 +824,11 @@ describe("Stack", () => { : Effect.void, releaseAll: Effect.void, }; - const { coordinatorLayer } = setupLayer(config, lease); + const { layer } = setupLayer(config, lease); yield* Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - const starting = yield* coordinator - .start() - .pipe(Effect.forkChild({ startImmediately: true })); + const stack = yield* Stack; + const starting = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(postgresReleaseStarted); const mailpitBeganConcurrently = yield* Effect.race( @@ -598,8 +839,8 @@ describe("Stack", () => { yield* Fiber.interrupt(starting); expect(mailpitBeganConcurrently).toBe(true); - yield* coordinator.stop(); - }).pipe(Effect.provide(coordinatorLayer)); + yield* stack.stop(); + }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); @@ -618,17 +859,18 @@ describe("Stack", () => { : Effect.void, }); const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; - const { coordinatorLayer } = setupLayer(config, noopPortLease(config.ports), spawner); + const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - const activationFiber = yield* coordinator - .activateService("auth") + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + const activationFiber = yield* activator + .activate("auth") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(spawnStarted); - const disposeFiber = yield* coordinator + const disposeFiber = yield* stack .dispose() .pipe(Effect.forkChild({ startImmediately: true })); yield* Fiber.join(disposeFiber); @@ -638,9 +880,108 @@ describe("Stack", () => { expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); - const error = yield* coordinator.activateService("auth").pipe(Effect.flip); + const error = yield* activator.activate("auth").pipe(Effect.flip); expect(error._tag).toBe("StackNotRunningError"); - }).pipe(Effect.provide(coordinatorLayer)); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + + it.live("uses the stack readiness deadline for explicit lazy activation and cleans up", () => + Effect.gen(function* () { + const spawner = mockChildProcessSpawner(); + let releasedAll = false; + const config = { + ...defaultConfig, + startupMode: "lazy", + readiness: { mode: "finite", timeoutMs: 1_000 }, + } satisfies ResolvedStackConfig; + const lease: PortLease = { + ...noopPortLease(config.ports), + releaseAll: Effect.sync(() => { + releasedAll = true; + }), + }; + const { layer } = setupLayer(config, lease, spawner); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + + const error = yield* activator.activate("auth").pipe(Effect.flip); + + expect(error._tag).toBe("StackReadinessError"); + if (error._tag === "StackReadinessError") { + expect(error.target).toBe("auth"); + expect(error.timeoutMs).toBe(1_000); + } + expect(releasedAll).toBe(true); + const spawnCountAfterDisposal = spawner.spawned.length; + expect((yield* activator.activate("postgres").pipe(Effect.flip))._tag).toBe( + "StackNotRunningError", + ); + for (const operation of [ + stack.start(), + stack.startService("postgres"), + stack.stopService("postgres"), + stack.restartService("postgres"), + stack.reloadFunctions(), + stack.reloadEdgeRuntime({ edgeRuntime: {} }), + ]) { + expect((yield* operation.pipe(Effect.flip))._tag).toBe("StackBuildError"); + } + yield* stack.stop(); + expect(spawner.spawned).toHaveLength(spawnCountAfterDisposal); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + + it.live("allows a finite wait override against an infinite stack policy", () => + Effect.gen(function* () { + const spawnStarted = yield* Deferred.make(); + const spawner = mockChildProcessSpawner({ + beforeSpawn: (record) => + record.args.some((arg) => + Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), + ) + ? Deferred.succeed(spawnStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + }); + let releasedAll = false; + const config = { + ...defaultConfig, + startupMode: "lazy", + readiness: { mode: "infinite" }, + } satisfies ResolvedStackConfig; + const lease: PortLease = { + ...noopPortLease(config.ports), + releaseAll: Effect.sync(() => { + releasedAll = true; + }), + }; + const { layer } = setupLayer(config, lease, spawner); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + const activation = yield* activator + .activate("auth") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(spawnStarted); + + const error = yield* stack + .waitAllReady({ mode: "finite", timeoutMs: 25 }) + .pipe(Effect.flip); + + expect(error._tag).toBe("StackReadinessError"); + if (error._tag === "StackReadinessError") { + expect(error.target).toBe("stack"); + expect(error.timeoutMs).toBe(25); + } + expect(releasedAll).toBe(true); + yield* Fiber.interrupt(activation); + }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); @@ -663,31 +1004,32 @@ describe("Stack", () => { if (authConfig === false) { throw new Error("Expected auth to be enabled in the default test config"); } - const { coordinatorLayer, spawner } = setupLayer({ + const { layer, spawner } = setupLayer({ ...defaultConfig, startupMode: "lazy", ports: { ...defaultPorts, authPort }, auth: { ...authConfig, port: authPort }, }); yield* Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; + const stack = yield* Stack; + const activator = yield* StackServiceActivator; const isAuthStart = (record: { readonly args: ReadonlyArray }) => record.args.some((arg) => Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), ); - yield* coordinator.start(); - yield* coordinator.activateService("auth"); + yield* stack.start(); + yield* activator.activate("auth"); const initialAuthStarts = spawner.spawned.filter(isAuthStart).length; expect(initialAuthStarts).toBeGreaterThan(0); - yield* coordinator.stopService("postgres"); - yield* coordinator.restartService("postgres"); - yield* coordinator.waitAllReady(); + yield* stack.stopService("postgres"); + yield* stack.restartService("postgres"); + yield* stack.waitAllReady(); expect(spawner.spawned.filter(isAuthStart)).toHaveLength(initialAuthStarts); - expect((yield* coordinator.getState("auth")).status).toBe("Stopped"); - yield* coordinator.stop(); - }).pipe(Effect.provide(coordinatorLayer)); + expect((yield* stack.getState("auth")).status).toBe("Stopped"); + yield* stack.stop(); + }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")); }); @@ -709,57 +1051,53 @@ describe("Stack", () => { it.live("keeps unactivated services dormant after a stop and start cycle", () => { const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; - const { coordinatorLayer } = setupLayer(config); + const { layer } = setupLayer(config); return Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - expect((yield* coordinator.getState("auth")).status).toBe("Dormant"); + const stack = yield* Stack; + yield* stack.start(); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); - yield* coordinator.stop(); - yield* coordinator.start(); + yield* stack.stop(); + yield* stack.start(); - expect((yield* coordinator.getState("auth")).status).toBe("Dormant"); - yield* coordinator.stop(); - }).pipe(Effect.provide(coordinatorLayer), Effect.timeout("5 seconds")); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); it.live("rejects a cached activation after the stack has stopped", () => { const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; - const { coordinatorLayer } = setupLayer(config); + const { layer } = setupLayer(config); return Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - yield* coordinator.stop(); + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + yield* stack.stop(); - const error = yield* coordinator.activateService("postgres").pipe(Effect.flip); + const error = yield* activator.activate("postgres").pipe(Effect.flip); expect(error._tag).toBe("StackNotRunningError"); - }).pipe(Effect.provide(coordinatorLayer), Effect.timeout("5 seconds")); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); it.live("preserves an explicitly stopped service across a stack restart", () => { const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; - const { coordinatorLayer } = setupLayer(config); + const { layer } = setupLayer(config); return Effect.gen(function* () { - const coordinator = yield* StackLifecycleCoordinator; - yield* coordinator.start(); - const stoppedState = yield* coordinator.allStateChanges().pipe( - Stream.filter((state) => state.name === "auth" && state.status === "Stopped"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* coordinator.stopService("auth"); - expect((yield* Fiber.join(stoppedState))._tag).toBe("Some"); - expect((yield* coordinator.getState("auth")).status).toBe("Stopped"); + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stopService("auth"); + yield* Effect.sleep("20 millis"); + expect((yield* stack.getState("auth")).status).toBe("Stopped"); - yield* coordinator.stop(); - yield* coordinator.start(); + yield* stack.stop(); + yield* stack.start(); - expect((yield* coordinator.getState("auth")).status).toBe("Stopped"); - yield* coordinator.stop(); - }).pipe(Effect.provide(coordinatorLayer), Effect.timeout("5 seconds")); + expect((yield* stack.getState("auth")).status).toBe("Stopped"); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); it.live("releases only the ports in a lazy service dependency closure", () => { diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index dc95e58583..924aaecab7 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -1,18 +1,11 @@ import { buildGraph } from "@supabase/process-compose"; import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; import { Effect, Layer, Context } from "effect"; -import type { CleanupTargets } from "./CleanupTargets.ts"; +import { dockerContainerName, type CleanupTargets } from "./CleanupTargets.ts"; import { StackBuildError } from "./errors.ts"; -import type { FunctionsConfig, ResolvedFunctionsConfig } from "./functions.ts"; import { generateJwks } from "./JwtGenerator.ts"; -import { - detectPlatform, - dockerHostAddress, - dockerNetworkArgs, - dockerPortMapArgs, -} from "./Platform.ts"; -import type { ServiceResolution } from "./resolve.ts"; -import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./services/analytics.ts"; +import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { makeEdgeRuntimeServiceDocker, @@ -21,7 +14,7 @@ import { import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; import { makeMailpitServiceDocker } from "./services/mailpit.ts"; import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; -import { makePoolerServiceDocker, poolerContainerPorts } from "./services/pooler.ts"; +import { makePoolerServiceDocker } from "./services/pooler.ts"; import { makePostgresInitService } from "./services/postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; @@ -38,271 +31,12 @@ import { dependencyTimeoutSecondsForServices, POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, } from "./services/health-budgets.ts"; -import type { PreparedStackArtifacts } from "./StackPreparation.ts"; +import type { PreparedStackArtifacts, ServiceResolution } from "./StackPreparation.ts"; import type { StackServiceProjectionCatalog } from "./StackStateProjection.ts"; -import type { AllocatedPorts } from "./PortAllocator.ts"; -import type { ServiceName, VersionManifest } from "./versions.ts"; - -export interface PostgresConfig { - readonly port?: number; - readonly dataDir?: string; - readonly version?: string; - /** - * When true (default), the bundled initial schema GRANTs that expose new tables, views, - * sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`, - * `service_role`) are kept in place. When false, those default privileges are revoked so the - * local stack matches the new cloud default and requires explicit GRANTs to surface entities - * through the Data API. - */ - readonly autoExposeNewTables?: boolean; -} - -export interface PostgrestConfig { - readonly schemas?: ReadonlyArray; - readonly extraSearchPath?: ReadonlyArray; - readonly maxRows?: number; - readonly version?: string; -} - -export interface AuthConfig { - readonly port?: number; - readonly siteUrl?: string; - readonly jwtExpiry?: number; - readonly externalUrl?: string; - readonly version?: string; -} - -export interface RealtimeConfig { - readonly port?: number; - readonly version?: string; - readonly tenantId?: string; - readonly encryptionKey?: string; - readonly secretKeyBase?: string; - readonly maxHeaderLength?: number; -} - -export interface EdgeRuntimeConfig { - readonly enabled?: boolean; - readonly port?: number; - readonly inspectorPort?: number; - readonly policy?: "oneshot" | "per_worker"; - readonly version?: string; - readonly env?: Readonly>; -} - -export interface StorageConfig { - readonly port?: number; - readonly dataDir?: string; - readonly fileSizeLimit?: string; - readonly s3ProtocolEnabled?: boolean; - readonly version?: string; -} - -export interface ImgproxyConfig { - readonly port?: number; - readonly version?: string; -} - -export interface MailpitConfig { - readonly port?: number; - readonly smtpPort?: number; - readonly pop3Port?: number; - readonly version?: string; - readonly adminEmail?: string; - readonly senderName?: string; -} - -export interface PgmetaConfig { - readonly port?: number; - readonly version?: string; -} - -export interface StudioConfig { - readonly port?: number; - readonly apiUrl?: string; - readonly version?: string; -} - -export interface AnalyticsConfig { - readonly port?: number; - readonly version?: string; - readonly backend?: "postgres" | "bigquery"; - readonly apiKey?: string; -} - -export interface VectorConfig { - readonly version?: string; -} - -export interface PoolerConfig { - readonly port?: number; - readonly apiPort?: number; - readonly mode?: "transaction" | "session"; - readonly version?: string; - readonly tenantId?: string; - readonly encryptionKey?: string; - readonly secretKeyBase?: string; - readonly defaultPoolSize?: number; - readonly maxClientConn?: number; -} - -export interface StackConfig { - readonly cacheRoot?: string; - readonly stackRoot?: string; - readonly runtimeRoot?: string; - readonly projectDir?: string; - readonly mode?: "native" | "auto" | "docker"; - /** Start all services immediately, or defer proxied services until first use. */ - readonly startupMode?: "eager" | "lazy"; - readonly jwtSecret?: string; - readonly port?: number; - readonly publishableKey?: string; - readonly secretKey?: string; - readonly functions?: FunctionsConfig | false; - readonly postgres?: PostgresConfig; - readonly postgrest?: PostgrestConfig | false; - readonly auth?: AuthConfig | false; - readonly edgeRuntime?: EdgeRuntimeConfig | false; - readonly realtime?: RealtimeConfig | false; - readonly storage?: StorageConfig | false; - readonly imgproxy?: ImgproxyConfig | false; - readonly mailpit?: MailpitConfig | false; - readonly pgmeta?: PgmetaConfig | false; - readonly studio?: StudioConfig | false; - readonly analytics?: AnalyticsConfig | false; - readonly vector?: VectorConfig | false; - readonly pooler?: PoolerConfig | false; -} - -export interface ResolvedPostgresConfig { - readonly port: number; - readonly dataDir: string; - readonly version: string; - readonly autoExposeNewTables: boolean; -} - -export interface ResolvedPostgrestConfig { - readonly port: number; - readonly adminPort: number; - readonly schemas: ReadonlyArray; - readonly extraSearchPath: ReadonlyArray; - readonly maxRows: number; - readonly version: string; -} - -export interface ResolvedAuthConfig { - readonly port: number; - readonly siteUrl: string; - readonly jwtExpiry: number; - readonly externalUrl: string; - readonly version: string; -} - -export interface ResolvedRealtimeConfig { - readonly port: number; - readonly version: string; - readonly tenantId: string; - readonly encryptionKey: string; - readonly secretKeyBase: string; - readonly maxHeaderLength: number; -} - -export interface ResolvedEdgeRuntimeConfig { - readonly enabled: boolean; - readonly port: number; - readonly inspectorPort: number; - readonly policy: "oneshot" | "per_worker"; - readonly version: string; - readonly env: Readonly>; -} - -export interface ResolvedStorageConfig { - readonly port: number; - readonly version: string; - readonly dataDir: string; - readonly fileSizeLimit: string; - readonly s3ProtocolEnabled: boolean; -} - -export interface ResolvedImgproxyConfig { - readonly port: number; - readonly version: string; -} - -export interface ResolvedMailpitConfig { - readonly port: number; - readonly smtpPort: number; - readonly pop3Port: number; - readonly version: string; - readonly adminEmail: string; - readonly senderName: string; -} - -export interface ResolvedPgmetaConfig { - readonly port: number; - readonly version: string; -} - -export interface ResolvedStudioConfig { - readonly port: number; - readonly version: string; - readonly apiUrl: string; -} - -export interface ResolvedAnalyticsConfig { - readonly port: number; - readonly version: string; - readonly backend: "postgres" | "bigquery"; - readonly apiKey: string; -} - -export interface ResolvedVectorConfig { - readonly version: string; -} - -export interface ResolvedPoolerConfig { - readonly port: number; - readonly apiPort: number; - readonly mode: "transaction" | "session"; - readonly version: string; - readonly tenantId: string; - readonly encryptionKey: string; - readonly secretKeyBase: string; - readonly defaultPoolSize: number; - readonly maxClientConn: number; -} - -export interface ResolvedStackConfig { - readonly cacheRoot: string; - readonly stackRoot: string; - readonly runtimeRoot: string; - readonly projectDir: string; - readonly mode: "native" | "auto" | "docker"; - readonly startupMode: "eager" | "lazy"; - readonly jwtSecret: string; - readonly ports: AllocatedPorts; - readonly apiPort: number; - readonly dbPort: number; - readonly publishableKey: string; - readonly secretKey: string; - readonly functions: ResolvedFunctionsConfig | false; - readonly autoManagedPaths: ReadonlyArray; - readonly anonJwt: string; - readonly serviceRoleJwt: string; - readonly postgres: ResolvedPostgresConfig; - readonly postgrest: ResolvedPostgrestConfig | false; - readonly auth: ResolvedAuthConfig | false; - readonly edgeRuntime: ResolvedEdgeRuntimeConfig | false; - readonly realtime: ResolvedRealtimeConfig | false; - readonly storage: ResolvedStorageConfig | false; - readonly imgproxy: ResolvedImgproxyConfig | false; - readonly mailpit: ResolvedMailpitConfig | false; - readonly pgmeta: ResolvedPgmetaConfig | false; - readonly studio: ResolvedStudioConfig | false; - readonly analytics: ResolvedAnalyticsConfig | false; - readonly vector: ResolvedVectorConfig | false; - readonly pooler: ResolvedPoolerConfig | false; -} +import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { VersionManifest } from "./versions.ts"; export interface BuildResult { readonly graph: ResolvedGraph; @@ -310,34 +44,23 @@ export interface BuildResult { readonly serviceProjection: StackServiceProjectionCatalog; } -const dockerOnlyServices = [ - "edge-runtime", - "realtime", - "storage", - "imgproxy", - "mailpit", - "pgmeta", - "studio", - "analytics", - "vector", - "pooler", -] as const; +const dockerOnlyServices = SERVICE_NAMES.filter( + (service) => serviceMetadata(service).runtimeSupport === "docker-only", +); + +// Serial health-check paths used by dependency waits; keep each path aligned +// with the corresponding service's transitive dependencies. +const postgresStartupPath: ReadonlyArray = ["postgres"]; +const storageStartupPath: ReadonlyArray = ["postgres", "storage"]; +const analyticsStartupPath: ReadonlyArray = ["postgres", "analytics"]; + +const postgresDependencyTimeoutSeconds = dependencyTimeoutSecondsForServices(postgresStartupPath); const dependsOnPostgres = (hasPostgresInit: boolean): ReadonlyArray => hasPostgresInit ? [{ service: "postgres-init", condition: "completed" }] : [{ service: "postgres", condition: "healthy" }]; -const POSTGRES_DEPENDENCY_TIMEOUT_SECONDS = dependencyTimeoutSecondsForServices(["postgres"]); -const STORAGE_DEPENDENCY_TIMEOUT_SECONDS = dependencyTimeoutSecondsForServices([ - "postgres", - "storage", -]); -const ANALYTICS_DEPENDENCY_TIMEOUT_SECONDS = dependencyTimeoutSecondsForServices([ - "postgres", - "analytics", -]); - const publicServiceProjection = ( defs: ReadonlyArray, hasPostgresInit: boolean, @@ -362,8 +85,6 @@ const publicServiceProjection = ( return serviceProjection; }; -const dockerContainerName = (service: string, apiPort: number) => `supabase-${service}-${apiPort}`; - const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => config.autoManagedPaths.some( (managedPath) => @@ -372,10 +93,8 @@ const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => path.startsWith(`${managedPath}\\`), ); -const resolvedConfigForService = ( - config: ResolvedStackConfig, - service: Exclude, -) => (service === "edge-runtime" ? config.edgeRuntime : config[service]); +const resolvedConfigForService = (config: ResolvedStackConfig, service: ServiceName) => + config[serviceMetadata(service).configKey]; export const validateResolvedConfig = ( config: ResolvedStackConfig, @@ -419,67 +138,22 @@ export const validateResolvedConfig = ( } }); -export const enabledServicesForConfig = ( - config: ResolvedStackConfig, -): ReadonlyArray => { - const services: ServiceName[] = ["postgres"]; +export const enabledServicesForConfig = (config: ResolvedStackConfig): ReadonlyArray => + SERVICE_NAMES.filter( + (service) => service === "postgres" || resolvedConfigForService(config, service) !== false, + ); - if (config.postgrest !== false) { - services.push("postgrest"); - } - if (config.auth !== false) { - services.push("auth"); - } - if (config.edgeRuntime !== false) { - services.push("edge-runtime"); - } - if (config.realtime !== false) { - services.push("realtime"); - } - if (config.storage !== false) { - services.push("storage"); - } - if (config.imgproxy !== false) { - services.push("imgproxy"); - } - if (config.mailpit !== false) { - services.push("mailpit"); - } - if (config.pgmeta !== false) { - services.push("pgmeta"); - } - if (config.studio !== false) { - services.push("studio"); - } - if (config.analytics !== false) { - services.push("analytics"); - } - if (config.vector !== false) { - services.push("vector"); - } - if (config.pooler !== false) { - services.push("pooler"); +export const versionsForConfig = (config: ResolvedStackConfig): Partial => { + const versions: Partial> = {}; + for (const service of enabledServicesForConfig(config)) { + const serviceConfig = resolvedConfigForService(config, service); + if (serviceConfig !== false) { + versions[service] = serviceConfig.version; + } } - - return services; + return versions; }; -export const versionsForConfig = (config: ResolvedStackConfig): Partial => ({ - postgres: config.postgres.version, - ...(config.postgrest === false ? {} : { postgrest: config.postgrest.version }), - ...(config.auth === false ? {} : { auth: config.auth.version }), - ...(config.edgeRuntime === false ? {} : { "edge-runtime": config.edgeRuntime.version }), - ...(config.realtime === false ? {} : { realtime: config.realtime.version }), - ...(config.storage === false ? {} : { storage: config.storage.version }), - ...(config.imgproxy === false ? {} : { imgproxy: config.imgproxy.version }), - ...(config.mailpit === false ? {} : { mailpit: config.mailpit.version }), - ...(config.pgmeta === false ? {} : { pgmeta: config.pgmeta.version }), - ...(config.studio === false ? {} : { studio: config.studio.version }), - ...(config.analytics === false ? {} : { analytics: config.analytics.version }), - ...(config.vector === false ? {} : { vector: config.vector.version }), - ...(config.pooler === false ? {} : { pooler: config.pooler.version }), -}); - const requirePreparedResolution = ( prepared: PreparedStackArtifacts, service: ServiceName, @@ -568,15 +242,17 @@ export class StackBuilder extends Context.Service< ); const hasPostgresInit = postgresResolution.type === "binary"; const postgresDeps = dependsOnPostgres(hasPostgresInit); + const postgresInitCompletionBudgetSeconds = hasPostgresInit + ? POSTGRES_INIT_COMPLETION_BUDGET_SECONDS + : 0; const postgresConsumerDependencyTimeoutSeconds = - POSTGRES_DEPENDENCY_TIMEOUT_SECONDS + - (hasPostgresInit ? POSTGRES_INIT_COMPLETION_BUDGET_SECONDS : 0); + postgresDependencyTimeoutSeconds + postgresInitCompletionBudgetSeconds; const storageDependencyTimeoutSeconds = - STORAGE_DEPENDENCY_TIMEOUT_SECONDS + - (hasPostgresInit ? POSTGRES_INIT_COMPLETION_BUDGET_SECONDS : 0); + dependencyTimeoutSecondsForServices(storageStartupPath) + + postgresInitCompletionBudgetSeconds; const analyticsDependencyTimeoutSeconds = - ANALYTICS_DEPENDENCY_TIMEOUT_SECONDS + - (hasPostgresInit ? POSTGRES_INIT_COMPLETION_BUDGET_SECONDS : 0); + dependencyTimeoutSecondsForServices(analyticsStartupPath) + + postgresInitCompletionBudgetSeconds; const jwtJwks = generateJwks(config.jwtSecret); const defs: Array = [ @@ -588,16 +264,18 @@ export class StackBuilder extends Context.Service< port: config.dbPort, dockerAccessible: needsDockerAccess, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], }) : makePostgresServiceDocker({ image: postgresResolution.image, dataDir: config.postgres.dataDir, port: config.dbPort, - networkArgs: dockerNetworkArgs(platform.os, [config.dbPort]), + platformOs: platform.os, jwtSecret: config.jwtSecret, jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, apiPort: config.apiPort, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], })), enabled: true, }, @@ -609,8 +287,9 @@ export class StackBuilder extends Context.Service< postgresDir: postgresResolution.path, dbPort: config.dbPort, autoExposeNewTables: config.postgres.autoExposeNewTables, + dependencies: [{ service: "postgres", condition: "healthy" }], }), - dependencyTimeoutSeconds: POSTGRES_DEPENDENCY_TIMEOUT_SECONDS, + dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, enabled: true, }); } @@ -626,6 +305,7 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, + dependencies: postgresDeps, }) : makePostgrestServiceDocker({ image: postgrestResolution.image, @@ -637,17 +317,10 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, - networkArgs: dockerNetworkArgs(platform.os, [ - config.postgrest.port, - config.postgrest.adminPort, - ]), + platformOs: platform.os, apiPort: config.apiPort, + dependencies: postgresDeps, })), - ...(hasPostgresInit - ? {} - : { - dependencies: [{ service: "postgres", condition: "healthy" as const }], - }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); @@ -683,7 +356,7 @@ export class StackBuilder extends Context.Service< smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, - networkArgs: dockerNetworkArgs(platform.os, [config.auth.port]), + platformOs: platform.os, apiPort: config.apiPort, dependencies: postgresDeps, })), @@ -713,7 +386,7 @@ export class StackBuilder extends Context.Service< inspectorPort: config.edgeRuntime.inspectorPort, policy: config.edgeRuntime.policy, env: config.edgeRuntime.env, - networkArgs: dockerNetworkArgs(platform.os, [config.edgeRuntime.port]), + platformOs: platform.os, dependencies: postgresDeps, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -730,11 +403,8 @@ export class StackBuilder extends Context.Service< webPort: config.mailpit.port, smtpPort: config.mailpit.smtpPort, pop3Port: config.mailpit.pop3Port, - networkArgs: dockerNetworkArgs(platform.os, [ - config.mailpit.port, - config.mailpit.smtpPort, - config.mailpit.pop3Port, - ]), + platformOs: platform.os, + dependencies: [], }), enabled: true, }); @@ -755,7 +425,7 @@ export class StackBuilder extends Context.Service< encryptionKey: config.realtime.encryptionKey, secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, - networkArgs: dockerNetworkArgs(platform.os, [config.realtime.port]), + platformOs: platform.os, dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -782,7 +452,7 @@ export class StackBuilder extends Context.Service< imgproxyUrl: config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", s3ProtocolEnabled: config.storage.s3ProtocolEnabled, - networkArgs: dockerNetworkArgs(platform.os, [config.storage.port]), + platformOs: platform.os, dependencies: postgresDeps, cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), }), @@ -800,7 +470,7 @@ export class StackBuilder extends Context.Service< port: config.imgproxy.port, apiPort: config.apiPort, dataDir: storageConfig === false ? "" : storageConfig.dataDir, - networkArgs: dockerNetworkArgs(platform.os, [config.imgproxy.port]), + platformOs: platform.os, dependencies: [{ service: "storage", condition: "healthy" }], }), dependencyTimeoutSeconds: storageDependencyTimeoutSeconds, @@ -817,7 +487,7 @@ export class StackBuilder extends Context.Service< port: config.pgmeta.port, dbHost: serviceHost, dbPort: config.dbPort, - networkArgs: dockerNetworkArgs(platform.os, [config.pgmeta.port]), + platformOs: platform.os, dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -827,25 +497,16 @@ export class StackBuilder extends Context.Service< if (config.analytics !== false) { const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); - const analyticsRuntimeNetwork = analyticsDockerRuntimeNetwork( - platform.os, - config.analytics.port, - serviceHost, - ); defs.push({ ...makeAnalyticsServiceDocker({ image: analyticsImage, apiPort: config.apiPort, hostPort: config.analytics.port, - listenPort: analyticsRuntimeNetwork.listenPort, - nodeHost: analyticsRuntimeNetwork.nodeHost, + platformOs: platform.os, dbHost: serviceHost, dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, - networkArgs: dockerPortMapArgs(platform.os, [ - { host: config.analytics.port, container: 4000 }, - ]), dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -863,7 +524,7 @@ export class StackBuilder extends Context.Service< serviceHost, analyticsPort: analyticsConfig === false ? 0 : analyticsConfig.port, analyticsApiKey: analyticsConfig === false ? "api-key" : analyticsConfig.apiKey, - networkArgs: dockerNetworkArgs(platform.os, []), + platformOs: platform.os, dependencies: [{ service: "analytics", condition: "healthy" }], }), dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, @@ -878,6 +539,8 @@ export class StackBuilder extends Context.Service< image: poolerImage, apiPort: config.apiPort, hostAdminPort: config.pooler.apiPort, + hostPort: config.pooler.port, + platformOs: platform.os, dbHost: serviceHost, dbPort: config.dbPort, poolMode: config.pooler.mode, @@ -887,19 +550,6 @@ export class StackBuilder extends Context.Service< tenantId: config.pooler.tenantId, encryptionKey: config.pooler.encryptionKey, secretKeyBase: config.pooler.secretKeyBase, - networkArgs: dockerPortMapArgs(platform.os, [ - { - host: config.pooler.apiPort, - container: poolerContainerPorts.admin, - }, - { - host: config.pooler.port, - container: - config.pooler.mode === "session" - ? poolerContainerPorts.session - : poolerContainerPorts.transaction, - }, - ]), dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -928,7 +578,7 @@ export class StackBuilder extends Context.Service< analyticsUrl: config.analytics !== false ? `http://${serviceHost}:${config.analytics.port}` : "", analyticsApiKey: config.analytics !== false ? config.analytics.apiKey : "api-key", - networkArgs: dockerNetworkArgs(platform.os, [config.studio.port]), + platformOs: platform.os, dependencies: config.analytics === false ? [{ service: "pgmeta", condition: "healthy" }] @@ -942,9 +592,9 @@ export class StackBuilder extends Context.Service< }); } - const dockerContainerNames = defs - .filter((def) => def.command === "docker") - .map((def) => dockerContainerName(def.name, config.apiPort)); + const dockerContainerNames = SERVICE_NAMES.filter((service) => + defs.some((def) => def.name === service && def.command === "docker"), + ).map((service) => dockerContainerName(service, config.apiPort)); const graph = yield* buildGraph(defs).pipe( Effect.mapError( diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 75935f65ac..d98bfb31e1 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -3,9 +3,10 @@ import { Deferred, Effect, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { candidateCleanupTargets } from "./cleanup.ts"; import { StackBuilder } from "./StackBuilder.ts"; import type { BuildResult } from "./StackBuilder.ts"; -import type { ResolvedStackConfig } from "./StackBuilder.ts"; +import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; import { enabledServicesForConfig, versionsForConfig } from "./StackBuilder.ts"; import { nativePostgresNeedsDockerAccess } from "./StackBuilder.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; @@ -47,6 +48,7 @@ const baseConfig: ResolvedStackConfig = { projectDir: "/tmp/supabase-project", mode: "auto", startupMode: "eager", + readiness: DEFAULT_STACK_READINESS_POLICY, jwtSecret: testJwtSecret, ports: basePorts, apiPort: 3000, @@ -196,6 +198,11 @@ describe("StackBuilder", () => { expect(graph.startOrder.length).toBe(4); expect(cleanupTargets.dockerContainerNames).toEqual([]); + expect(candidateCleanupTargets(baseConfig).dockerContainerNames).toEqual([ + `supabase-postgres-${baseConfig.apiPort}`, + `supabase-postgrest-${baseConfig.apiPort}`, + `supabase-auth-${baseConfig.apiPort}`, + ]); const names = graph.startOrder.map((s) => s.name); expect(names).toContain("postgres"); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts new file mode 100644 index 0000000000..3c108d4170 --- /dev/null +++ b/packages/stack/src/StackConfig.ts @@ -0,0 +1,312 @@ +import { Schema } from "effect"; +import type { ResolvedFunctionsBundle } from "./functions.ts"; +import type { AllocatedPorts } from "./PortAllocator.ts"; + +type StackMode = "native" | "auto" | "docker"; +type StackStartupMode = "eager" | "lazy"; + +export type ReadinessPolicy = + | { readonly mode: "finite"; readonly timeoutMs: number } + | { readonly mode: "infinite" }; + +export type ReadyOptions = { readonly mode: "inherit" } | ReadinessPolicy; + +const ReadinessPolicySchema = Schema.Union([ + Schema.Struct({ + mode: Schema.Literal("finite"), + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), + }), + Schema.Struct({ mode: Schema.Literal("infinite") }), +]); + +/** The single wire representation accepted by Effect, Promise, and daemon Adapters. */ +export const ReadyOptionsSchema = Schema.Union([ + Schema.Struct({ mode: Schema.Literal("inherit") }), + ReadinessPolicySchema, +]); + +export const inheritReadyOptions: ReadyOptions = { mode: "inherit" }; + +/** Standalone stacks wait at most three minutes unless a caller or launch Adapter chooses otherwise. */ +export const DEFAULT_STACK_READINESS_POLICY: ReadinessPolicy = { + mode: "finite", + timeoutMs: 180_000, +}; + +/** Resolve readiness with per-call policy taking precedence over stack policy and package default. */ +export const resolveReadinessPolicy = (input: { + readonly readyOptions?: ReadyOptions; + readonly stackPolicy?: ReadinessPolicy; +}): ReadinessPolicy => + input.readyOptions === undefined || input.readyOptions.mode === "inherit" + ? (input.stackPolicy ?? DEFAULT_STACK_READINESS_POLICY) + : input.readyOptions; + +export interface PostgresConfig { + readonly port?: number; + readonly dataDir?: string; + readonly version?: string; + /** + * When true (default), the bundled initial schema GRANTs that expose new tables, views, + * sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`, + * `service_role`) are kept in place. When false, those default privileges are revoked so the + * local stack matches the new cloud default and requires explicit GRANTs to surface entities + * through the Data API. + */ + readonly autoExposeNewTables?: boolean; +} + +export interface PostgrestConfig { + readonly schemas?: ReadonlyArray; + readonly extraSearchPath?: ReadonlyArray; + readonly maxRows?: number; + readonly version?: string; +} + +export interface AuthConfig { + readonly port?: number; + readonly siteUrl?: string; + readonly jwtExpiry?: number; + readonly externalUrl?: string; + readonly version?: string; +} + +export interface RealtimeConfig { + readonly port?: number; + readonly version?: string; + readonly tenantId?: string; + readonly encryptionKey?: string; + readonly secretKeyBase?: string; + readonly maxHeaderLength?: number; +} + +export interface EdgeRuntimeConfig { + readonly enabled?: boolean; + readonly port?: number; + readonly inspectorPort?: number; + readonly policy?: "oneshot" | "per_worker"; + readonly version?: string; + readonly env?: Readonly>; +} + +export interface StorageConfig { + readonly port?: number; + readonly dataDir?: string; + readonly fileSizeLimit?: string; + readonly s3ProtocolEnabled?: boolean; + readonly version?: string; +} + +export interface ImgproxyConfig { + readonly port?: number; + readonly version?: string; +} + +export interface MailpitConfig { + readonly port?: number; + readonly smtpPort?: number; + readonly pop3Port?: number; + readonly version?: string; + readonly adminEmail?: string; + readonly senderName?: string; +} + +export interface PgmetaConfig { + readonly port?: number; + readonly version?: string; +} + +export interface StudioConfig { + readonly port?: number; + readonly apiUrl?: string; + readonly version?: string; +} + +export interface AnalyticsConfig { + readonly port?: number; + readonly version?: string; + readonly backend?: "postgres" | "bigquery"; + readonly apiKey?: string; +} + +export interface VectorConfig { + readonly version?: string; +} + +export interface PoolerConfig { + readonly port?: number; + readonly apiPort?: number; + readonly mode?: "transaction" | "session"; + readonly version?: string; + readonly tenantId?: string; + readonly encryptionKey?: string; + readonly secretKeyBase?: string; + readonly defaultPoolSize?: number; + readonly maxClientConn?: number; +} + +export interface StackConfig { + readonly cacheRoot?: string; + readonly stackRoot?: string; + readonly runtimeRoot?: string; + readonly projectDir?: string; + readonly mode?: StackMode; + /** Start all services immediately, or defer proxied services until first use. */ + readonly startupMode?: StackStartupMode; + /** Stack-wide readiness policy. Per-call ReadyOptions take precedence. */ + readonly readiness?: ReadinessPolicy; + readonly jwtSecret?: string; + readonly port?: number; + readonly publishableKey?: string; + readonly secretKey?: string; + readonly functions?: ResolvedFunctionsBundle | false; + readonly postgres?: PostgresConfig; + readonly postgrest?: PostgrestConfig | false; + readonly auth?: AuthConfig | false; + readonly edgeRuntime?: EdgeRuntimeConfig | false; + readonly realtime?: RealtimeConfig | false; + readonly storage?: StorageConfig | false; + readonly imgproxy?: ImgproxyConfig | false; + readonly mailpit?: MailpitConfig | false; + readonly pgmeta?: PgmetaConfig | false; + readonly studio?: StudioConfig | false; + readonly analytics?: AnalyticsConfig | false; + readonly vector?: VectorConfig | false; + readonly pooler?: PoolerConfig | false; +} + +export interface ResolvedPostgresConfig { + readonly port: number; + readonly dataDir: string; + readonly version: string; + readonly autoExposeNewTables: boolean; +} + +export interface ResolvedPostgrestConfig { + readonly port: number; + readonly adminPort: number; + readonly schemas: ReadonlyArray; + readonly extraSearchPath: ReadonlyArray; + readonly maxRows: number; + readonly version: string; +} + +export interface ResolvedAuthConfig { + readonly port: number; + readonly siteUrl: string; + readonly jwtExpiry: number; + readonly externalUrl: string; + readonly version: string; +} + +export interface ResolvedRealtimeConfig { + readonly port: number; + readonly version: string; + readonly tenantId: string; + readonly encryptionKey: string; + readonly secretKeyBase: string; + readonly maxHeaderLength: number; +} + +export interface ResolvedEdgeRuntimeConfig { + readonly enabled: boolean; + readonly port: number; + readonly inspectorPort: number; + readonly policy: "oneshot" | "per_worker"; + readonly version: string; + readonly env: Readonly>; +} + +export interface ResolvedStorageConfig { + readonly port: number; + readonly version: string; + readonly dataDir: string; + readonly fileSizeLimit: string; + readonly s3ProtocolEnabled: boolean; +} + +export interface ResolvedImgproxyConfig { + readonly port: number; + readonly version: string; +} + +export interface ResolvedMailpitConfig { + readonly port: number; + readonly smtpPort: number; + readonly pop3Port: number; + readonly version: string; + readonly adminEmail: string; + readonly senderName: string; +} + +export interface ResolvedPgmetaConfig { + readonly port: number; + readonly version: string; +} + +export interface ResolvedStudioConfig { + readonly port: number; + readonly version: string; + readonly apiUrl: string; +} + +export interface ResolvedAnalyticsConfig { + readonly port: number; + readonly version: string; + readonly backend: "postgres" | "bigquery"; + readonly apiKey: string; +} + +export interface ResolvedVectorConfig { + readonly version: string; +} + +export interface ResolvedPoolerConfig { + readonly port: number; + readonly apiPort: number; + readonly mode: "transaction" | "session"; + readonly version: string; + readonly tenantId: string; + readonly encryptionKey: string; + readonly secretKeyBase: string; + readonly defaultPoolSize: number; + readonly maxClientConn: number; +} + +export interface ResolvedStackConfig { + readonly cacheRoot: string; + readonly stackRoot: string; + readonly runtimeRoot: string; + readonly projectDir: string; + readonly mode: StackMode; + readonly startupMode: StackStartupMode; + readonly readiness: ReadinessPolicy; + readonly jwtSecret: string; + readonly ports: AllocatedPorts; + readonly apiPort: number; + readonly dbPort: number; + readonly publishableKey: string; + readonly secretKey: string; + readonly functions: ResolvedFunctionsBundle | false; + readonly autoManagedPaths: ReadonlyArray; + readonly anonJwt: string; + readonly serviceRoleJwt: string; + readonly postgres: ResolvedPostgresConfig; + readonly postgrest: ResolvedPostgrestConfig | false; + readonly auth: ResolvedAuthConfig | false; + readonly edgeRuntime: ResolvedEdgeRuntimeConfig | false; + readonly realtime: ResolvedRealtimeConfig | false; + readonly storage: ResolvedStorageConfig | false; + readonly imgproxy: ResolvedImgproxyConfig | false; + readonly mailpit: ResolvedMailpitConfig | false; + readonly pgmeta: ResolvedPgmetaConfig | false; + readonly studio: ResolvedStudioConfig | false; + readonly analytics: ResolvedAnalyticsConfig | false; + readonly vector: ResolvedVectorConfig | false; + readonly pooler: ResolvedPoolerConfig | false; +} + +export interface ResolvedDaemonConfig extends ResolvedStackConfig { + readonly name: string; + readonly projectDir: string; +} diff --git a/packages/stack/src/StackConfig.unit.test.ts b/packages/stack/src/StackConfig.unit.test.ts new file mode 100644 index 0000000000..21978dcd9e --- /dev/null +++ b/packages/stack/src/StackConfig.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; +import { + DEFAULT_STACK_READINESS_POLICY, + ReadyOptionsSchema, + resolveReadinessPolicy, + type ReadinessPolicy, +} from "./StackConfig.ts"; + +const finite = (timeoutMs: number): ReadinessPolicy => ({ mode: "finite", timeoutMs }); + +describe("resolveReadinessPolicy", () => { + it("uses the finite package default when neither level chooses a policy", () => { + expect(resolveReadinessPolicy({})).toEqual(DEFAULT_STACK_READINESS_POLICY); + }); + + it("inherits finite and infinite stack policies", () => { + expect( + resolveReadinessPolicy({ + readyOptions: { mode: "inherit" }, + stackPolicy: finite(180_000), + }), + ).toEqual(finite(180_000)); + expect( + resolveReadinessPolicy({ + readyOptions: { mode: "inherit" }, + stackPolicy: { mode: "infinite" }, + }), + ).toEqual({ mode: "infinite" }); + }); + + it("allows shorter and longer per-call finite policies", () => { + expect( + resolveReadinessPolicy({ + readyOptions: finite(5_000), + stackPolicy: finite(180_000), + }), + ).toEqual(finite(5_000)); + expect( + resolveReadinessPolicy({ + readyOptions: finite(300_000), + stackPolicy: finite(180_000), + }), + ).toEqual(finite(300_000)); + }); + + it("allows either level to opt into or override infinite waiting", () => { + expect( + resolveReadinessPolicy({ + readyOptions: { mode: "infinite" }, + stackPolicy: finite(180_000), + }), + ).toEqual({ mode: "infinite" }); + expect( + resolveReadinessPolicy({ + readyOptions: finite(30_000), + stackPolicy: { mode: "infinite" }, + }), + ).toEqual(finite(30_000)); + }); +}); + +describe("ReadyOptionsSchema", () => { + const decode = Schema.decodeUnknownSync(ReadyOptionsSchema); + + it("accepts the three readiness override modes", () => { + expect(decode({ mode: "inherit" })).toEqual({ mode: "inherit" }); + expect(decode({ mode: "infinite" })).toEqual({ mode: "infinite" }); + expect(decode({ mode: "finite", timeoutMs: 25 })).toEqual({ + mode: "finite", + timeoutMs: 25, + }); + }); + + it("rejects malformed and non-positive finite deadlines", () => { + expect(() => decode({ mode: "finite", timeoutMs: 0 })).toThrow(); + expect(() => decode({ mode: "finite", timeoutMs: -1 })).toThrow(); + expect(() => decode({ mode: "forever" })).toThrow(); + }); +}); diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts new file mode 100644 index 0000000000..38e33e5e79 --- /dev/null +++ b/packages/stack/src/StackConfigResolver.ts @@ -0,0 +1,628 @@ +import { mkdtempSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect, Schema } from "effect"; +import { StackBuildError, toStackError } from "./errors.ts"; +import { resolvedFunctionsBundleSchemaForProject } from "./functions.ts"; +import { + defaultJwtSecret, + defaultPublishableKey, + defaultSecretKey, + generateJwt, +} from "./JwtGenerator.ts"; +import { + DEFAULT_MANAGED_STACK_NAME, + defaultCacheRoot, + defaultManagedProjectsRoot, + defaultManagedRuntimeRoot, + defaultManagedStackRoot, + shortTempPrefixRoot, +} from "./paths.ts"; +import { + allocatePorts, + DEFAULT_PORTS, + PORT_FIELDS, + type AllocatedPorts, + type PortAllocationError, + type PortInput, + type PortSelectionOptions, +} from "./PortAllocator.ts"; +import { StackMetadataSchema } from "./StackMetadata.ts"; +import { resolveReadinessPolicy } from "./StackConfig.ts"; +import type { + AnalyticsConfig, + AuthConfig, + EdgeRuntimeConfig, + ImgproxyConfig, + MailpitConfig, + PgmetaConfig, + PoolerConfig, + PostgrestConfig, + RealtimeConfig, + ResolvedAnalyticsConfig, + ResolvedAuthConfig, + ResolvedDaemonConfig, + ResolvedEdgeRuntimeConfig, + ResolvedImgproxyConfig, + ResolvedMailpitConfig, + ResolvedPgmetaConfig, + ResolvedPoolerConfig, + ResolvedPostgrestConfig, + ResolvedRealtimeConfig, + ResolvedStackConfig, + ResolvedStorageConfig, + ResolvedStudioConfig, + ResolvedVectorConfig, + StackConfig, + StorageConfig, + StudioConfig, + VectorConfig, +} from "./StackConfig.ts"; +import { DEFAULT_VERSIONS } from "./ServiceCatalog.ts"; + +const StackMetadataFileSchema = Schema.fromJsonString(StackMetadataSchema); +const decodeStackMetadataFile = Schema.decodeUnknownSync(StackMetadataFileSchema); + +export function defaultManagedStackName(_cwd: string): string { + return DEFAULT_MANAGED_STACK_NAME; +} + +export interface ResolveConfigOptions { + readonly stackRoot?: string; + readonly runtimeRoot?: string; + readonly preferredPorts?: Partial; + readonly reservedPorts?: ReadonlySet; + readonly portAllocator?: ( + input: PortInput, + options: PortSelectionOptions, + ) => Effect.Effect; +} + +interface ResolvedRoots { + readonly cacheRoot: string; + readonly stackRoot: string; + readonly runtimeRoot: string; + readonly autoManagedPaths: ReadonlyArray; +} + +const makeTempRoot = (prefix: string) => mkdtempSync(join(shortTempPrefixRoot(), prefix)); + +const resolveRoots = (config: StackConfig, opts: ResolveConfigOptions): ResolvedRoots => { + const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); + const autoManagedPaths: string[] = []; + + const stackRoot = + opts.stackRoot ?? + config.stackRoot ?? + (() => { + const dir = makeTempRoot("sb-stack-"); + autoManagedPaths.push(dir); + return dir; + })(); + + const runtimeRoot = + opts.runtimeRoot ?? + config.runtimeRoot ?? + (() => { + const dir = makeTempRoot("sb-run-"); + autoManagedPaths.push(dir); + return dir; + })(); + + return { + cacheRoot, + stackRoot, + runtimeRoot, + autoManagedPaths, + }; +}; + +const resolveDataDir = ( + explicitDir: string | undefined, + stackRoot: string, + suffix: string, +): string => explicitDir ?? join(stackRoot, "data", suffix); + +async function readStackMetadataFile(filePath: string) { + try { + const content = await readFile(filePath, "utf8"); + return decodeStackMetadataFile(content); + } catch { + return undefined; + } +} + +async function readOwnedPorts(stackRoot: string): Promise { + const metadata = await readStackMetadataFile(join(stackRoot, "stack.json")); + return metadata?.ports; +} + +async function readReservedPorts( + projectsRoot: string, + currentStackRoot: string, +): Promise> { + const reserved = new Set(); + + let projectEntries: Array<{ isDirectory(): boolean; name: string }>; + try { + projectEntries = await readdir(projectsRoot, { withFileTypes: true }); + } catch { + return reserved; + } + + await Promise.all( + projectEntries.map(async (projectEntry) => { + if (!projectEntry.isDirectory()) { + return; + } + + const stacksRoot = join(projectsRoot, projectEntry.name, "stacks"); + let stackEntries: Array<{ isDirectory(): boolean; name: string }>; + try { + stackEntries = await readdir(stacksRoot, { withFileTypes: true }); + } catch { + return; + } + + await Promise.all( + stackEntries.map(async (stackEntry) => { + if (!stackEntry.isDirectory()) { + return; + } + + const stackRoot = join(stacksRoot, stackEntry.name); + if (stackRoot === currentStackRoot) { + return; + } + + const ports = (await readStackMetadataFile(join(stackRoot, "stack.json")))?.ports; + if (ports === undefined) { + return; + } + + for (const field of PORT_FIELDS) { + reserved.add(ports[field]); + } + }), + ); + }), + ); + + return reserved; +} + +async function readReservedPortsInStacksRoot( + stacksRoot: string, + currentStackRoot: string, +): Promise> { + const reserved = new Set(); + + let stackEntries: Array<{ isDirectory(): boolean; name: string }>; + try { + stackEntries = await readdir(stacksRoot, { withFileTypes: true }); + } catch { + return reserved; + } + + await Promise.all( + stackEntries.map(async (stackEntry) => { + if (!stackEntry.isDirectory()) { + return; + } + + const stackRoot = join(stacksRoot, stackEntry.name); + if (stackRoot === currentStackRoot) { + return; + } + + const ports = (await readStackMetadataFile(join(stackRoot, "stack.json")))?.ports; + if (ports === undefined) { + return; + } + + for (const field of PORT_FIELDS) { + reserved.add(ports[field]); + } + }), + ); + + return reserved; +} + +function resolvePostgrestConfig( + input: PostgrestConfig | undefined, + raw: PostgrestConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedPostgrestConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.postgrestPort, + adminPort: ports.postgrestAdminPort, + schemas: cfg.schemas ?? ["public", "graphql_public"], + extraSearchPath: cfg.extraSearchPath ?? ["public", "extensions"], + maxRows: cfg.maxRows ?? 1000, + version: cfg.version ?? DEFAULT_VERSIONS.postgrest, + }; +} + +function resolveAuthConfig( + input: AuthConfig | undefined, + raw: AuthConfig | false | undefined, + ports: AllocatedPorts, + apiPort: number, +): ResolvedAuthConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.authPort, + siteUrl: cfg.siteUrl ?? "http://localhost:3000", + jwtExpiry: cfg.jwtExpiry ?? 3600, + externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, + version: cfg.version ?? DEFAULT_VERSIONS.auth, + }; +} + +function resolveRealtimeConfig( + input: RealtimeConfig | undefined, + raw: RealtimeConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedRealtimeConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.realtimePort, + version: cfg.version ?? DEFAULT_VERSIONS.realtime, + tenantId: cfg.tenantId ?? "realtime-dev", + encryptionKey: cfg.encryptionKey ?? "supabaserealtime", + secretKeyBase: + cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + maxHeaderLength: cfg.maxHeaderLength ?? 4096, + }; +} + +function resolveEdgeRuntimeConfig( + input: EdgeRuntimeConfig | undefined, + raw: EdgeRuntimeConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedEdgeRuntimeConfig | false { + if (raw === false || raw?.enabled === false) return false; + const cfg = input ?? {}; + return { + enabled: cfg.enabled ?? true, + port: ports.edgeRuntimePort, + inspectorPort: ports.edgeRuntimeInspectorPort, + policy: cfg.policy ?? "per_worker", + version: cfg.version ?? DEFAULT_VERSIONS["edge-runtime"], + env: cfg.env ?? {}, + }; +} + +async function resolveFunctionsConfig(config: StackConfig, projectDir: string) { + if (config.functions === undefined || config.functions === false) { + return false; + } + try { + return await Schema.decodeUnknownPromise(resolvedFunctionsBundleSchemaForProject(projectDir))( + config.functions, + ); + } catch (cause) { + throw new StackBuildError({ detail: "Invalid Edge Functions bundle", cause }); + } +} + +function resolveStorageConfig( + input: StorageConfig | undefined, + raw: StorageConfig | false | undefined, + ports: AllocatedPorts, + opts: ResolveConfigOptions, +): ResolvedStorageConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.storagePort, + version: cfg.version ?? DEFAULT_VERSIONS.storage, + dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), + fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", + s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, + }; +} + +function resolveImgproxyConfig( + input: ImgproxyConfig | undefined, + raw: ImgproxyConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedImgproxyConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.imgproxyPort, + version: cfg.version ?? DEFAULT_VERSIONS.imgproxy, + }; +} + +function resolveMailpitConfig( + input: MailpitConfig | undefined, + raw: MailpitConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedMailpitConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.mailpitPort, + smtpPort: ports.mailpitSmtpPort, + pop3Port: ports.mailpitPop3Port, + version: cfg.version ?? DEFAULT_VERSIONS.mailpit, + adminEmail: cfg.adminEmail ?? "admin@email.com", + senderName: cfg.senderName ?? "Admin", + }; +} + +function resolvePgmetaConfig( + input: PgmetaConfig | undefined, + raw: PgmetaConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedPgmetaConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.pgmetaPort, + version: cfg.version ?? DEFAULT_VERSIONS.pgmeta, + }; +} + +function resolveStudioConfig( + input: StudioConfig | undefined, + raw: StudioConfig | false | undefined, + ports: AllocatedPorts, + apiPort: number, +): ResolvedStudioConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.studioPort, + version: cfg.version ?? DEFAULT_VERSIONS.studio, + apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, + }; +} + +function resolveAnalyticsConfig( + input: AnalyticsConfig | undefined, + raw: AnalyticsConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedAnalyticsConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.analyticsPort, + version: cfg.version ?? DEFAULT_VERSIONS.analytics, + backend: cfg.backend ?? "postgres", + apiKey: cfg.apiKey ?? "api-key", + }; +} + +function resolveVectorConfig( + input: VectorConfig | undefined, + raw: VectorConfig | false | undefined, +): ResolvedVectorConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + version: cfg.version ?? DEFAULT_VERSIONS.vector, + }; +} + +function resolvePoolerConfig( + input: PoolerConfig | undefined, + raw: PoolerConfig | false | undefined, + ports: AllocatedPorts, +): ResolvedPoolerConfig | false { + if (raw === false) return false; + const cfg = input ?? {}; + return { + port: ports.poolerPort, + apiPort: ports.poolerApiPort, + mode: cfg.mode ?? "transaction", + version: cfg.version ?? DEFAULT_VERSIONS.pooler, + tenantId: cfg.tenantId ?? "pooler-dev", + encryptionKey: cfg.encryptionKey ?? "12345678901234567890123456789032", + secretKeyBase: + cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + defaultPoolSize: cfg.defaultPoolSize ?? 20, + maxClientConn: cfg.maxClientConn ?? 100, + }; +} + +export async function resolveConfig( + input?: StackConfig, + opts: ResolveConfigOptions = {}, +): Promise { + const config = input ?? {}; + const projectDir = config.projectDir ?? process.cwd(); + const functions = await resolveFunctionsConfig(config, projectDir); + const resolvedMode = config.mode ?? "auto"; + const roots = resolveRoots(config, opts); + const postgresInput = config.postgres ?? {}; + const postgrestInput = config.postgrest !== false ? (config.postgrest ?? undefined) : undefined; + const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; + const edgeRuntimeEnabled = + !(resolvedMode === "native" && config.edgeRuntime === undefined) && + config.edgeRuntime !== false && + (config.edgeRuntime?.enabled ?? true) !== false; + const realtimeEnabled = config.realtime !== undefined && config.realtime !== false; + const storageEnabled = config.storage !== undefined && config.storage !== false; + const imgproxyEnabled = config.imgproxy !== undefined && config.imgproxy !== false; + const mailpitEnabled = config.mailpit !== undefined && config.mailpit !== false; + const pgmetaEnabled = config.pgmeta !== undefined && config.pgmeta !== false; + const studioEnabled = config.studio !== undefined && config.studio !== false; + const analyticsEnabled = config.analytics !== undefined && config.analytics !== false; + const vectorEnabled = config.vector !== undefined && config.vector !== false; + const poolerEnabled = config.pooler !== undefined && config.pooler !== false; + const edgeRuntimeInput = edgeRuntimeEnabled ? (config.edgeRuntime ?? undefined) : undefined; + const realtimeInput = realtimeEnabled ? (config.realtime ?? undefined) : undefined; + const storageInput = storageEnabled ? (config.storage ?? undefined) : undefined; + const imgproxyInput = imgproxyEnabled ? (config.imgproxy ?? undefined) : undefined; + const mailpitInput = mailpitEnabled ? (config.mailpit ?? undefined) : undefined; + const pgmetaInput = pgmetaEnabled ? (config.pgmeta ?? undefined) : undefined; + const studioInput = studioEnabled ? (config.studio ?? undefined) : undefined; + const analyticsInput = analyticsEnabled ? (config.analytics ?? undefined) : undefined; + const vectorInput = vectorEnabled ? (config.vector ?? undefined) : undefined; + const poolerInput = poolerEnabled ? (config.pooler ?? undefined) : undefined; + + const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); + + const ports = await Effect.runPromise( + (opts.portAllocator ?? allocatePorts)( + { + apiPort: config.port, + dbPort: postgresInput.port, + authPort: authInput?.port, + postgrestPort: undefined, + postgrestAdminPort: undefined, + edgeRuntimePort: edgeRuntimeInput?.port, + edgeRuntimeInspectorPort: edgeRuntimeInput?.inspectorPort, + realtimePort: realtimeInput?.port, + storagePort: storageInput?.port, + imgproxyPort: imgproxyInput?.port, + mailpitPort: mailpitInput?.port, + mailpitSmtpPort: mailpitInput?.smtpPort, + mailpitPop3Port: mailpitInput?.pop3Port, + pgmetaPort: pgmetaInput?.port, + studioPort: studioInput?.port, + analyticsPort: analyticsInput?.port, + poolerPort: poolerInput?.port, + poolerApiPort: poolerInput?.apiPort, + }, + { + preferred: opts.preferredPorts, + reserved: opts.reservedPorts, + }, + ), + ).catch((error: unknown) => { + throw toStackError(error); + }); + + const jwtSecret = config.jwtSecret ?? defaultJwtSecret; + const anonJwt = generateJwt(jwtSecret, "anon"); + const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); + + return { + cacheRoot: roots.cacheRoot, + stackRoot: roots.stackRoot, + runtimeRoot: roots.runtimeRoot, + projectDir, + mode: resolvedMode, + startupMode: config.startupMode ?? "eager", + readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), + jwtSecret, + ports, + apiPort: ports.apiPort, + dbPort: ports.dbPort, + publishableKey: config.publishableKey ?? defaultPublishableKey, + secretKey: config.secretKey ?? defaultSecretKey, + functions, + autoManagedPaths: roots.autoManagedPaths, + anonJwt, + serviceRoleJwt, + postgres: { + port: ports.dbPort, + dataDir: postgresDataDir, + version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, + }, + postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), + auth: resolveAuthConfig(authInput, config.auth, ports, ports.apiPort), + edgeRuntime: edgeRuntimeEnabled + ? resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) + : false, + realtime: realtimeEnabled + ? resolveRealtimeConfig(realtimeInput, config.realtime, ports) + : false, + storage: storageEnabled + ? resolveStorageConfig(storageInput, config.storage, ports, { + ...opts, + stackRoot: roots.stackRoot, + }) + : false, + imgproxy: imgproxyEnabled + ? resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) + : false, + mailpit: mailpitEnabled ? resolveMailpitConfig(mailpitInput, config.mailpit, ports) : false, + pgmeta: pgmetaEnabled ? resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) : false, + studio: studioEnabled + ? resolveStudioConfig(studioInput, config.studio, ports, ports.apiPort) + : false, + analytics: analyticsEnabled + ? resolveAnalyticsConfig(analyticsInput, config.analytics, ports) + : false, + vector: vectorEnabled ? resolveVectorConfig(vectorInput, config.vector) : false, + pooler: poolerEnabled ? resolvePoolerConfig(poolerInput, config.pooler, ports) : false, + }; +} + +export type DaemonConfigInput = Omit & { + readonly cwd: string; + readonly name?: string; + readonly projectDir?: string; + readonly projectStateRoot?: string; +}; + +export function sanitizeDaemonConfigInput( + input: DaemonConfigInput & { readonly functions?: unknown }, +): DaemonConfigInput { + const { functions: _functions, ...config } = input; + return config; +} + +export async function resolveDaemonConfig( + input: DaemonConfigInput, + opts: Pick = {}, +): Promise { + const { cwd, name, projectDir, projectStateRoot, ...stackConfig } = + sanitizeDaemonConfigInput(input); + if (stackConfig.stackRoot !== undefined || stackConfig.runtimeRoot !== undefined) { + throw new Error("Managed daemon stacks derive stackRoot and runtimeRoot automatically"); + } + const effectiveProjectDir = projectDir ?? cwd; + const resolvedName = name ?? defaultManagedStackName(effectiveProjectDir); + const cacheRoot = stackConfig.cacheRoot ?? defaultCacheRoot(); + const stackRoot = + projectStateRoot !== undefined + ? join(projectStateRoot, "stacks", resolvedName) + : defaultManagedStackRoot(cacheRoot, effectiveProjectDir, resolvedName); + const runtimeRoot = defaultManagedRuntimeRoot(stackRoot); + const savedPorts = await readOwnedPorts(stackRoot); + const reservedPortSets = await Promise.all([ + readReservedPorts(defaultManagedProjectsRoot(cacheRoot), stackRoot), + projectStateRoot === undefined + ? Promise.resolve>(new Set()) + : readReservedPortsInStacksRoot(join(projectStateRoot, "stacks"), stackRoot), + ]); + const reservedPorts = new Set(); + for (const ports of reservedPortSets) { + for (const port of ports) { + reservedPorts.add(port); + } + } + const resolved = await resolveConfig( + { + ...stackConfig, + cacheRoot, + stackRoot, + runtimeRoot, + projectDir: effectiveProjectDir, + }, + { + stackRoot, + runtimeRoot, + preferredPorts: savedPorts ?? DEFAULT_PORTS, + reservedPorts, + portAllocator: opts.portAllocator, + }, + ); + return { + ...resolved, + name: resolvedName, + projectDir: effectiveProjectDir, + }; +} diff --git a/packages/stack/src/StackLifecycleCoordinator.ts b/packages/stack/src/StackLifecycleCoordinator.ts deleted file mode 100644 index 86b4097bae..0000000000 --- a/packages/stack/src/StackLifecycleCoordinator.ts +++ /dev/null @@ -1,871 +0,0 @@ -import { LogBuffer, Orchestrator } from "@supabase/process-compose"; -import { ServiceNotFoundError } from "@supabase/process-compose"; -import type { LogEntry, ResolvedGraph, ServiceReadyError } from "@supabase/process-compose"; -import { - Deferred, - Effect, - FileSystem, - Layer, - Path, - Ref, - Semaphore, - Context, - Stream, - SubscriptionRef, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type { CleanupTargets } from "./CleanupTargets.ts"; -import { cleanupLocalStackResources } from "./cleanup.ts"; -import { StackBuildError, StackNotRunningError } from "./errors.ts"; -import { configureFunctionsRuntime, type FunctionsConfig } from "./functions.ts"; -import { detectPlatform, dockerHostAddress } from "./Platform.ts"; -import type { PortLease } from "./PortAllocator.ts"; -import { - activationTargetsForService, - eagerServices, - lifecycleTargetsForService, -} from "./ServiceActivation.ts"; -import { portFieldsForService } from "./ServicePorts.ts"; -import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import type { PreparedStackArtifacts } from "./StackPreparation.ts"; -import { - enabledServicesForConfig, - StackBuilder, - validateResolvedConfig, - versionsForConfig, - type ResolvedStackConfig, -} from "./StackBuilder.ts"; -import { projectStackStates, type StackServiceProjectionCatalog } from "./StackStateProjection.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; -import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; - -type LifecyclePhase = - | "idle" - | "preparing" - | "prepared" - | "starting" - | "running" - | "stopping" - | "stopped"; - -interface RuntimeState { - readonly orchestrator: Orchestrator["Service"]; - readonly graph: ResolvedGraph; - readonly serviceProjection: StackServiceProjectionCatalog; - readonly cleanupTargets: CleanupTargets; -} - -const sameState = (a: StackServiceState | undefined, b: StackServiceState): boolean => - a?.name === b.name && - a.status === b.status && - a.pid === b.pid && - a.exitCode === b.exitCode && - a.restartCount === b.restartCount && - a.startedAt === b.startedAt && - a.error === b.error; - -const initialPublicStates = (config: ResolvedStackConfig): ReadonlyArray => - enabledServicesForConfig(config).map( - (name) => - new StackServiceState({ - name, - status: "Pending", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - -const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { - const apiUrl = `http://127.0.0.1:${config.apiPort}`; - return { - url: apiUrl, - dbUrl: `postgresql://postgres:postgres@127.0.0.1:${config.dbPort}/postgres`, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - anonJwt: config.anonJwt, - serviceRoleJwt: config.serviceRoleJwt, - serviceEndpoints: { - ...(config.auth === false ? {} : { auth: `${apiUrl}/auth/v1` }), - ...(config.postgrest === false ? {} : { postgrest: `${apiUrl}/rest/v1` }), - ...(config.edgeRuntime === false - ? {} - : { - functions: `${apiUrl}/functions/v1`, - edge_runtime: `${apiUrl}/functions/v1`, - }), - ...(config.realtime === false ? {} : { realtime: `${apiUrl}/realtime/v1` }), - ...(config.storage === false - ? {} - : { - storage: `${apiUrl}/storage/v1`, - storage_s3: `${apiUrl}/storage/v1/s3`, - }), - ...(config.imgproxy === false || config.startupMode === "lazy" - ? {} - : { imgproxy: `http://127.0.0.1:${config.imgproxy.port}` }), - ...(config.mailpit === false - ? {} - : { - mailpit: `http://127.0.0.1:${config.mailpit.port}`, - mailpit_smtp: `smtp://127.0.0.1:${config.mailpit.smtpPort}`, - mailpit_pop3: `pop3://127.0.0.1:${config.mailpit.pop3Port}`, - }), - ...(config.pgmeta === false ? {} : { pgmeta: `${apiUrl}/pg` }), - ...(config.studio === false ? {} : { studio: `http://127.0.0.1:${config.studio.port}` }), - ...(config.analytics === false ? {} : { analytics: `${apiUrl}/analytics/v1` }), - ...(config.pooler === false - ? {} - : { - pooler: `postgresql://postgres:postgres@127.0.0.1:${config.pooler.port}/postgres`, - pooler_admin: `http://127.0.0.1:${config.pooler.apiPort}`, - }), - }, - }; -}; - -const changedStatesBetween = ( - previous: ReadonlyArray | undefined, - current: ReadonlyArray, -): ReadonlyArray => { - if (previous === undefined) { - return current; - } - - const previousByName = new Map(previous.map((state) => [state.name, state] as const)); - return current.filter((state) => !sameState(previousByName.get(state.name), state)); -}; - -export class StackLifecycleCoordinator extends Context.Service< - StackLifecycleCoordinator, - { - readonly getInfo: () => Effect.Effect; - readonly getCleanupTargets: () => Effect.Effect; - readonly start: () => Effect.Effect; - readonly stop: () => Effect.Effect; - readonly dispose: () => Effect.Effect; - readonly startService: ( - name: string, - ) => Effect.Effect; - readonly activateService: ( - name: ServiceName, - ) => Effect.Effect< - void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackNotRunningError - >; - readonly stopService: ( - name: string, - ) => Effect.Effect; - readonly restartService: ( - name: string, - ) => Effect.Effect; - readonly reloadFunctions: ( - opts?: FunctionsConfig, - ) => Effect.Effect; - readonly reloadEdgeRuntime: ( - opts: EdgeRuntimeReloadConfig, - ) => Effect.Effect; - readonly getState: (name: string) => Effect.Effect; - readonly getAllStates: () => Effect.Effect>; - readonly stateChanges: ( - name: string, - ) => Effect.Effect, ServiceNotFoundError>; - readonly allStateChanges: () => Stream.Stream; - readonly waitReady: ( - name: string, - ) => Effect.Effect; - readonly waitAllReady: () => Effect.Effect; - readonly subscribeLogs: (name: string) => Stream.Stream; - readonly subscribeAllLogs: (services?: ReadonlyArray) => Stream.Stream; - readonly logHistory: (name: string, limit?: number) => Effect.Effect>; - readonly logHistoryAll: ( - limit?: number, - services?: ReadonlyArray, - ) => Effect.Effect>; - } ->()("stack/StackLifecycleCoordinator") { - static layer = ( - config: ResolvedStackConfig, - portLease: PortLease, - ): Layer.Layer< - StackLifecycleCoordinator, - StackBuildError, - | StackBuilder - | StackPreparation - | ChildProcessSpawner.ChildProcessSpawner - | StackMetadataPersistence - | FileSystem.FileSystem - | Path.Path - > => - Layer.effect( - this, - Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const metadataPersistence = yield* StackMetadataPersistence; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const scope = yield* Effect.scope; - - const info = stackInfoFor(config); - const enabledServices = enabledServicesForConfig(config); - const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); - const phaseRef = yield* Ref.make("idle"); - const lifecycleLock = Semaphore.makeUnsafe(1); - const projectionLock = Semaphore.makeUnsafe(1); - - const logBufferServices = yield* Layer.buildWithScope(LogBuffer.layer, scope); - const logBuffer = Context.get(logBufferServices, LogBuffer); - - const updateState = (nextState: StackServiceState) => - SubscriptionRef.update(stateRef, (current) => { - const previous = current.find((entry) => entry.name === nextState.name); - if (sameState(previous, nextState)) { - return current; - } - return current.some((entry) => entry.name === nextState.name) - ? current.map((entry) => (entry.name === nextState.name ? nextState : entry)) - : [...current, nextState]; - }); - - const syncProjectedStates = ( - orchestrator: Orchestrator["Service"], - serviceProjection: StackServiceProjectionCatalog, - ) => - Effect.gen(function* () { - const rawStates = yield* orchestrator.getAllStates(); - yield* Effect.forEach(projectStackStates(rawStates, serviceProjection), updateState, { - discard: true, - }); - }).pipe(projectionLock.withPermit); - - const requireKnownService = (name: string) => - Effect.gen(function* () { - const currentStates = SubscriptionRef.getUnsafe(stateRef); - const match = currentStates.find((state) => state.name === name); - if (match === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); - } - return match; - }); - const requireKnownServiceName = ( - name: string, - ): Effect.Effect => - Effect.gen(function* () { - yield* requireKnownService(name); - const service = SERVICE_NAMES.find((candidate) => candidate === name); - if (service === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); - } - return service; - }); - - let preparedArtifacts: PreparedStackArtifacts | undefined; - let prepareDeferred: Deferred.Deferred | undefined; - let runtimeState: RuntimeState | undefined; - let runtimeDeferred: Deferred.Deferred | undefined; - - const ensurePrepared = Effect.suspend(() => { - if (preparedArtifacts !== undefined) { - return Effect.succeed(preparedArtifacts); - } - if (prepareDeferred !== undefined) { - return Deferred.await(prepareDeferred); - } - - const deferred = Deferred.makeUnsafe(); - prepareDeferred = deferred; - - const effect = Effect.gen(function* () { - yield* validateResolvedConfig(config); - yield* Ref.set(phaseRef, "preparing"); - - let prepared: PreparedStackArtifacts | undefined; - yield* preparation - .prepareEvents({ - mode: config.mode, - services: enabledServicesForConfig(config), - versions: versionsForConfig(config), - }) - .pipe( - Stream.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to prepare stack assets", - cause, - }), - ), - ) - .pipe( - Stream.runForEach((event) => { - switch (event._tag) { - case "ServiceDownloadStarted": - return updateState( - new StackServiceState({ - name: event.service, - status: "Downloading", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - case "ServiceDownloadFinished": - return updateState( - new StackServiceState({ - name: event.service, - status: "Pending", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - case "PreparationCompleted": - return Effect.sync(() => { - prepared = event.artifacts; - }); - } - }), - ); - - if (prepared === undefined) { - return yield* Effect.fail( - new StackBuildError({ - detail: "Stack preparation completed without prepared artifacts", - }), - ); - } - - yield* Ref.set(phaseRef, "prepared"); - return prepared; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - preparedArtifacts = value; - }), - ), - Effect.onError(() => Ref.set(phaseRef, "idle")), - Effect.ensuring( - Effect.sync(() => { - prepareDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); - - const ensureRuntime = Effect.suspend(() => { - if (runtimeState !== undefined) { - return Effect.succeed(runtimeState); - } - if (runtimeDeferred !== undefined) { - return Deferred.await(runtimeDeferred); - } - - const deferred = Deferred.makeUnsafe(); - runtimeDeferred = deferred; - - const effect = Effect.gen(function* () { - const prepared = yield* ensurePrepared; - const { graph, serviceProjection, cleanupTargets } = yield* builder.build( - config, - prepared, - ); - - yield* metadataPersistence.persistCleanupTargets(cleanupTargets).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to persist stack cleanup metadata", - cause, - }), - ), - ); - - const orchLayer = Orchestrator.layer(graph).pipe( - Layer.provide(Layer.succeed(LogBuffer, logBuffer)), - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ); - const orchServices = yield* Layer.buildWithScope(orchLayer, scope); - const orchestrator = Context.get(orchServices, Orchestrator); - - yield* syncProjectedStates(orchestrator, serviceProjection); - yield* orchestrator.allStateChanges().pipe( - Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), - Effect.ignore, - Effect.forkIn(scope), - ); - - return { - orchestrator, - graph, - serviceProjection, - cleanupTargets, - } satisfies RuntimeState; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - runtimeState = value; - }), - ), - Effect.ensuring( - Effect.sync(() => { - runtimeDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); - - let disposed = false; - const runtimeHost = Effect.gen(function* () { - const prepared = yield* ensurePrepared; - const platform = yield* detectPlatform; - const edgeRuntimeResolution = prepared.resolutions["edge-runtime"]; - return { - hostname: - edgeRuntimeResolution?.type === "docker" - ? dockerHostAddress(platform.os) - : "127.0.0.1", - }; - }); - const providePlatform = ( - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - const configureFunctions = ( - nextConfig: ResolvedStackConfig, - ): Effect.Effect => - Effect.gen(function* () { - yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost)); - }).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to configure Edge Functions", - cause, - }), - ), - ); - const configWithFunctionOptions = (opts?: FunctionsConfig): ResolvedStackConfig => { - if (opts === undefined) { - return config; - } - const base = config.functions === false ? { noVerifyJwt: false } : config.functions; - return { - ...config, - functions: { - envFile: opts.envFile ?? base.envFile, - noVerifyJwt: opts.noVerifyJwt ?? base.noVerifyJwt, - }, - }; - }; - const configWithEdgeRuntimeOptions = ( - opts: EdgeRuntimeReloadConfig, - ): Effect.Effect => - Effect.gen(function* () { - if (config.edgeRuntime === false || opts.edgeRuntime.enabled === false) { - return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); - } - - const base = configWithFunctionOptions(opts.functions); - return { - ...base, - edgeRuntime: { - ...config.edgeRuntime, - enabled: opts.edgeRuntime.enabled ?? config.edgeRuntime.enabled, - inspectorPort: opts.edgeRuntime.inspectorPort ?? config.edgeRuntime.inspectorPort, - policy: opts.edgeRuntime.policy ?? config.edgeRuntime.policy, - env: opts.edgeRuntime.env ?? config.edgeRuntime.env, - }, - }; - }); - const publicAllStateChanges = () => - SubscriptionRef.changes(stateRef).pipe( - Stream.mapAccum< - ReadonlyArray | undefined, - ReadonlyArray, - StackServiceState - >( - () => undefined, - (previous, current) => [current, changedStatesBetween(previous, current)], - ), - ); - const withLifecycleLock = lifecycleLock.withPermit; - const syncRuntimeProjectedStates = (runtime: RuntimeState) => - syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); - const serviceStartOptions = { - beforeStart: (name: string) => portLease.reserve(portFieldsForService(name)), - beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), - }; - const knownServiceError = (service: string, cause: ServiceNotFoundError) => - new StackBuildError({ - detail: `Prepared graph does not contain enabled service ${service}`, - cause, - }); - const beginStartTargets = ( - root: ServiceName, - allowExplicitlyStopped: ReadonlySet, - ) => - Effect.gen(function* () { - const runtime = yield* ensureRuntime; - const targets = activationTargetsForService(enabledServices, root); - const targetClosure = new Set( - targets.flatMap((target) => - runtime.graph.startOrderFor(target).map((definition) => definition.name), - ), - ); - - for (const dependency of targetClosure) { - const state = yield* runtime.orchestrator - .getState(dependency) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(dependency, cause)), - ), - ); - const publicDependency = SERVICE_NAMES.find((candidate) => candidate === dependency); - if ( - state.desired === "stopped" && - publicDependency !== undefined && - !allowExplicitlyStopped.has(publicDependency) - ) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Cannot activate ${root} because dependency ${dependency} was explicitly stopped`, - }), - ); - } - } - - for (const target of targets) { - yield* runtime.orchestrator - .startService(target, serviceStartOptions) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(target, cause)), - ), - ); - } - return { runtime, targets }; - }); - const waitForTargets = ({ - runtime, - targets, - }: { - readonly runtime: RuntimeState; - readonly targets: ReadonlyArray; - }) => - Effect.gen(function* () { - yield* Effect.forEach( - targets, - (target) => - runtime.orchestrator - .waitReady(target) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(target, cause)), - ), - ), - { concurrency: "unbounded", discard: true }, - ); - yield* syncRuntimeProjectedStates(runtime); - }); - const inspectStartedTargets = (root: ServiceName) => - Effect.gen(function* () { - const runtime = yield* ensureRuntime; - const targets = activationTargetsForService(enabledServices, root); - const states = yield* Effect.forEach(targets, (target) => - runtime.orchestrator - .getState(target) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(target, cause)), - ), - ), - ); - if (states.some((state) => state.desired !== "running")) { - return undefined; - } - return { - runtime, - targets, - ready: states.every( - (state) => - state.status === "Healthy" || - (state.status === "Stopped" && state.exitCode === 0), - ), - }; - }); - const requireRunningPhase = Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* Effect.fail(new StackNotRunningError({ phase })); - } - }); - const disposeOnce = () => - Effect.gen(function* () { - if (disposed) { - return; - } - disposed = true; - yield* Ref.set(phaseRef, "stopping"); - yield* cleanupLocalStackResources({ - stop: () => - runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), - cleanupTargets: runtimeState?.cleanupTargets ?? { dockerContainerNames: [] }, - config, - }).pipe( - Effect.ensuring(portLease.releaseAll), - Effect.ensuring(Ref.set(phaseRef, "stopped")), - ); - }).pipe(withLifecycleLock); - - yield* Effect.addFinalizer(disposeOnce); - - return { - getInfo: () => Effect.succeed(info), - getCleanupTargets: () => - Effect.succeed(runtimeState?.cleanupTargets ?? { dockerContainerNames: [] }), - start: () => - Effect.gen(function* () { - yield* Ref.set(phaseRef, "starting"); - const runtime = yield* ensureRuntime; - yield* configureFunctions(config); - - if (config.startupMode === "lazy") { - const readiness: Array> = - []; - if ( - runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") - ) { - yield* runtime.orchestrator - .startService("postgres-init", serviceStartOptions) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError("postgres-init", cause)), - ), - ); - readiness.push( - runtime.orchestrator - .waitReady("postgres-init") - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError("postgres-init", cause)), - ), - ), - ); - } - for (const service of eagerServices(enabledServices)) { - const started = yield* beginStartTargets( - service, - new Set(lifecycleTargetsForService(enabledServices, service)), - ); - readiness.push(waitForTargets(started)); - } - yield* Effect.all(readiness, { concurrency: "unbounded", discard: true }); - } else { - yield* runtime.orchestrator.start(serviceStartOptions); - yield* runtime.orchestrator.waitAllReady(); - yield* syncRuntimeProjectedStates(runtime); - } - yield* Ref.set(phaseRef, "running"); - }).pipe( - Effect.onError(() => Ref.set(phaseRef, "stopped")), - withLifecycleLock, - ), - stop: () => - Effect.gen(function* () { - if (runtimeState === undefined) { - yield* Ref.set(phaseRef, "stopped"); - return; - } - yield* Ref.set(phaseRef, "stopping"); - yield* runtimeState.orchestrator.stop(); - yield* Ref.set(phaseRef, "stopped"); - }).pipe(withLifecycleLock), - dispose: disposeOnce, - startService: (name) => - Effect.gen(function* () { - const started = yield* Effect.gen(function* () { - const service = yield* requireKnownServiceName(name); - return yield* beginStartTargets( - service, - new Set(lifecycleTargetsForService(enabledServices, service)), - ); - }).pipe(withLifecycleLock); - yield* waitForTargets(started); - }), - activateService: (name) => - Effect.gen(function* () { - yield* requireRunningPhase; - const service = yield* requireKnownServiceName(name); - const existing = yield* inspectStartedTargets(service); - if (existing?.ready === true) { - // Close the race with a concurrent stack stop before taking - // the lock-free healthy-request fast path. - yield* requireRunningPhase; - return; - } - if (existing !== undefined) { - yield* waitForTargets(existing); - return; - } - const started = yield* Effect.gen(function* () { - yield* requireRunningPhase; - const concurrentlyStarted = yield* inspectStartedTargets(service); - if (concurrentlyStarted !== undefined) return concurrentlyStarted; - return yield* beginStartTargets(service, new Set()); - }).pipe(withLifecycleLock); - yield* waitForTargets(started); - }), - stopService: (name) => - Effect.gen(function* () { - const service = yield* requireKnownServiceName(name); - const runtime = yield* ensureRuntime; - for (const target of lifecycleTargetsForService( - enabledServices, - service, - ).toReversed()) { - yield* runtime.orchestrator.stopService(target); - } - }).pipe(withLifecycleLock), - restartService: (name) => - Effect.gen(function* () { - const started = yield* Effect.gen(function* () { - const service = yield* requireKnownServiceName(name); - const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.restartService(service, serviceStartOptions); - return { runtime, targets: [service] }; - }).pipe(withLifecycleLock); - yield* waitForTargets(started); - }), - reloadFunctions: (opts) => - Effect.gen(function* () { - const started = yield* Effect.gen(function* () { - yield* requireKnownService("edge-runtime"); - yield* configureFunctions(configWithFunctionOptions(opts)); - const runtime = yield* ensureRuntime; - const state = yield* runtime.orchestrator.getState("edge-runtime"); - if (state.desired !== "running") { - return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); - } - yield* runtime.orchestrator.restartService("edge-runtime", serviceStartOptions); - return { runtime, targets: ["edge-runtime"] as const }; - }).pipe(withLifecycleLock); - yield* waitForTargets(started); - }), - reloadEdgeRuntime: (opts) => - Effect.gen(function* () { - const started = yield* Effect.gen(function* () { - yield* requireKnownService("edge-runtime"); - const nextConfig = yield* configWithEdgeRuntimeOptions(opts); - const prepared = yield* ensurePrepared; - const runtime = yield* ensureRuntime; - const buildResult = yield* builder.build(nextConfig, prepared); - const edgeRuntimeDef = buildResult.graph.startOrder.find( - (def) => def.name === "edge-runtime", - ); - - if (edgeRuntimeDef === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); - } - - yield* configureFunctions(nextConfig); - yield* runtime.orchestrator - .updateServiceDefinition("edge-runtime", edgeRuntimeDef) - .pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to update edge-runtime service definition", - cause, - }), - ), - ); - const state = yield* runtime.orchestrator.getState("edge-runtime"); - if (state.desired !== "running") { - return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); - } - yield* runtime.orchestrator.restartService("edge-runtime", serviceStartOptions); - return { runtime, targets: ["edge-runtime"] as const }; - }).pipe(withLifecycleLock); - yield* waitForTargets(started); - }), - getState: (name) => - Effect.gen(function* () { - const currentStates = SubscriptionRef.getUnsafe(stateRef); - const match = currentStates.find((state) => state.name === name); - if (match === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); - } - return match; - }), - getAllStates: () => Effect.sync(() => SubscriptionRef.getUnsafe(stateRef)), - stateChanges: (name) => - Effect.gen(function* () { - yield* requireKnownService(name); - return Stream.filter(publicAllStateChanges(), (state) => state.name === name); - }), - allStateChanges: publicAllStateChanges, - waitReady: (name) => - Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* Effect.fail( - new StackBuildError({ - detail: `Cannot wait for service ${name} while the stack is ${phase}`, - }), - ); - } - yield* requireKnownServiceName(name); - const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.waitReady(name); - yield* syncRuntimeProjectedStates(runtime); - }), - waitAllReady: () => - Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* Effect.fail( - new StackBuildError({ - detail: `Cannot wait for stack readiness while the stack is ${phase}`, - }), - ); - } - const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.waitAllReady(); - yield* syncRuntimeProjectedStates(runtime); - }), - subscribeLogs: (name) => logBuffer.subscribe(name), - subscribeAllLogs: (services) => - services === undefined || services.length === 0 - ? logBuffer.subscribeAll() - : logBuffer - .subscribeAll() - .pipe(Stream.filter((entry) => services.includes(entry.service))), - logHistory: (name, limit) => logBuffer.history(name, limit), - logHistoryAll: (limit, services) => logBuffer.historyAll(limit, services), - }; - }), - ); -} diff --git a/packages/stack/src/StackMetadata.ts b/packages/stack/src/StackMetadata.ts index 1704993751..aa4a3fb0d9 100644 --- a/packages/stack/src/StackMetadata.ts +++ b/packages/stack/src/StackMetadata.ts @@ -1,7 +1,8 @@ import { Schema } from "effect"; import { CleanupTargetsSchema, type CleanupTargets } from "./CleanupTargets.ts"; import { AllocatedPortsSchema, type AllocatedPorts } from "./PortAllocator.ts"; -import type { ResolvedStackConfig } from "./StackBuilder.ts"; +import { serviceMetadata } from "./ServiceCatalog.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; import { SERVICE_NAMES, type ServiceName, type VersionManifest } from "./versions.ts"; const VersionManifestSchema = Schema.Struct({ @@ -76,15 +77,10 @@ export const STACK_METADATA_SCHEMA_VERSION = 1; export function runningServiceVersionsForConfig( config: ResolvedStackConfig, ): PartialVersionManifest { - const versions: Partial> = { - postgres: config.postgres.version, - }; + const versions: Partial> = {}; for (const service of SERVICE_NAMES) { - if (service === "postgres") { - continue; - } - const serviceConfig = service === "edge-runtime" ? config.edgeRuntime : config[service]; + const serviceConfig = config[serviceMetadata(service).configKey]; if (serviceConfig !== false) { versions[service] = serviceConfig.version; } diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index a363d40a78..c0feb4924d 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -3,8 +3,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; import type { ChecksumMismatchError } from "./errors.ts"; import { DockerPullError } from "./errors.ts"; -import type { ServiceResolution } from "./resolve.ts"; -import { isDockerOnlyService } from "./ServiceArtifacts.ts"; +import { isDockerOnlyService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES, @@ -17,6 +16,10 @@ export interface PreparedStackArtifacts { readonly resolutions: Partial>; } +export type ServiceResolution = + | { readonly type: "binary"; readonly path: string } + | { readonly type: "docker"; readonly image: string }; + export interface StackPreparationInput { readonly versions?: Partial; readonly services?: ReadonlyArray; @@ -133,9 +136,11 @@ export const prepareAssetsWithDependencies = ( concurrency: "unbounded", }); - const artifacts = { - resolutions: Object.fromEntries(results) as PreparedStackArtifacts["resolutions"], - } satisfies PreparedStackArtifacts; + const resolutions: Partial> = {}; + for (const [service, resolution] of results) { + resolutions[service] = resolution; + } + const artifacts = { resolutions } satisfies PreparedStackArtifacts; yield* publishEvent?.(new PreparationCompleted({ artifacts })) ?? Effect.void; return artifacts; }); diff --git a/packages/stack/src/UnixSocketSse.integration.test.ts b/packages/stack/src/UnixSocketSse.integration.test.ts index e6694814da..c7de5133b0 100644 --- a/packages/stack/src/UnixSocketSse.integration.test.ts +++ b/packages/stack/src/UnixSocketSse.integration.test.ts @@ -10,7 +10,7 @@ import { DaemonServer } from "./DaemonServer.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; -import { unixHttpClientLayer } from "./bun.ts"; +import { unixHttpClientLayer } from "./platform-bun.ts"; const REFERENCE_IDLE_TIMEOUT_SECONDS = 1; // Keep the idle gap just past a short reference timeout so the suite stays fast. diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 2642e1b6c6..2d05640881 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -1,59 +1,17 @@ import { BunServices } from "@effect/platform-bun"; -import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { fileURLToPath } from "node:url"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { - createStack as createStackCore, - type PlatformFactory, - type StackHandle, -} from "./createStack.ts"; +import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; import { prefetch as prefetchEffect, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; import { defaultCacheRoot } from "./paths.ts"; +import { platformFactory } from "./platform-bun.ts"; import { StackPreparation } from "./StackPreparation.ts"; -import type { StackConfig } from "./StackBuilder.ts"; -import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -interface BunUnixRequestInit extends RequestInit { - readonly unix: string; -} - -export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { - request: (socketPath, path, init) => - Effect.tryPromise({ - try: () => { - const requestInit: BunUnixRequestInit = { - ...init, - unix: socketPath, - }; - return fetch(`http://localhost${path}`, requestInit); - }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), - }), -}); - -// --------------------------------------------------------------------------- -// Platform values — for use with Effect layer factories -// --------------------------------------------------------------------------- - -/** Bun platform factory for use with foregroundLayer / daemonLayer. */ -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - BunServices.layer, - Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), - ); - -/** Path to the Bun daemon entry point for use with daemonLayer. */ -export const daemonEntryPoint: string = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); - -// --------------------------------------------------------------------------- -// Promise API — convenience wrappers for non-Effect consumers -// --------------------------------------------------------------------------- +import type { StackConfig } from "./StackConfig.ts"; export async function createStack(config?: StackConfig): Promise { return createStackCore(config, platformFactory); diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts index 59ee0d2cae..419c405384 100644 --- a/packages/stack/src/cleanup.ts +++ b/packages/stack/src/cleanup.ts @@ -1,8 +1,16 @@ import { execFileSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { Duration, Effect } from "effect"; -import type { CleanupTargets } from "./CleanupTargets.ts"; -import type { ResolvedStackConfig } from "./StackBuilder.ts"; +import { dockerContainerName, type CleanupTargets } from "./CleanupTargets.ts"; +import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; + +export const candidateCleanupTargets = (config: ResolvedStackConfig): CleanupTargets => ({ + dockerContainerNames: SERVICE_NAMES.filter((service) => { + const serviceConfig = config[serviceMetadata(service).configKey]; + return service === "postgres" || serviceConfig !== false; + }).map((service) => dockerContainerName(service, config.apiPort)), +}); /** * Force-remove Docker containers by name. Best-effort safety net — diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index f0b22c2f6d..4e124db6b8 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -1,107 +1,50 @@ import type { LogEntry } from "@supabase/process-compose"; -import { readdir, readFile } from "node:fs/promises"; -import { mkdtempSync } from "node:fs"; -import { join } from "node:path"; -import { Duration, Effect, type Layer, ManagedRuntime, Schema, Stream } from "effect"; -import { FileSystem, Path } from "effect"; +import { Context, Effect, FileSystem, type Layer, ManagedRuntime, Path, Stream } from "effect"; import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; -import type { CleanupTargets } from "./CleanupTargets.ts"; +import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; import { toStackError } from "./errors.ts"; -import type { FunctionsConfig } from "./functions.ts"; -import { - defaultJwtSecret, - defaultPublishableKey, - defaultSecretKey, - generateJwt, -} from "./JwtGenerator.ts"; -import { - daemonLayer, - foregroundLayer, - type DaemonConfig, - type DaemonStartError, -} from "./layers.ts"; -import { - DEFAULT_MANAGED_STACK_NAME, - defaultCacheRoot, - defaultManagedProjectsRoot, - defaultManagedRuntimeRoot, - defaultManagedStackRoot, - shortTempPrefixRoot, -} from "./paths.ts"; -import { - allocatePorts, - DEFAULT_PORTS, - PORT_FIELDS, - reservePorts, - type AllocatedPorts, - type PortInput, - type PortAllocationError, - type PortLease, - type PortSelectionOptions, -} from "./PortAllocator.ts"; +import type { FunctionsReloadConfig } from "./functions.ts"; +import { foregroundLayer } from "./layers.ts"; +import { LocalStackLifecycle } from "./LocalStack.ts"; +import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; -import { StackMetadataSchema } from "./StackMetadata.ts"; -import { InvalidStackStateError, StackAlreadyRunningError } from "./StateManager.ts"; import { Stack } from "./Stack.ts"; import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; +import type { ReadyOptions, ResolvedStackConfig, StackConfig } from "./StackConfig.ts"; +import { resolveConfig } from "./StackConfigResolver.ts"; import type { StackServiceState } from "./StackServiceState.ts"; -import { UnixHttpClient } from "./UnixHttpClient.ts"; -import type { - AnalyticsConfig, - AuthConfig, - EdgeRuntimeConfig, - ImgproxyConfig, - MailpitConfig, - PgmetaConfig, - PoolerConfig, - PostgrestConfig, - RealtimeConfig, - ResolvedAnalyticsConfig, - ResolvedAuthConfig, - ResolvedEdgeRuntimeConfig, - ResolvedImgproxyConfig, - ResolvedMailpitConfig, - ResolvedPgmetaConfig, - ResolvedPoolerConfig, - ResolvedPostgrestConfig, - ResolvedRealtimeConfig, - ResolvedStackConfig, - ResolvedStorageConfig, - ResolvedStudioConfig, - ResolvedVectorConfig, - StackConfig, - StorageConfig, - StudioConfig, - VectorConfig, -} from "./StackBuilder.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; -const StackMetadataFileSchema = Schema.fromJsonString(StackMetadataSchema); -const decodeStackMetadataFile = Schema.decodeUnknownSync(StackMetadataFileSchema); - -export type PlatformServices = +type PlatformServices = | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | HttpServer.HttpServer; -export type PlatformLayer = Layer.Layer; +type PlatformLayer = Layer.Layer; /** Supplies the platform HTTP server used by the stack and HTTP proxy. */ -export interface PlatformFactoryOptions { +interface PlatformFactoryOptions { readonly apiPort: number; readonly releaseApiPort: Effect.Effect; } export type PlatformFactory = (options: PlatformFactoryOptions) => PlatformLayer; -export interface ReadyOptions { - readonly timeout?: number; -} - -export function defaultManagedStackName(_cwd: string): string { - return DEFAULT_MANAGED_STACK_NAME; +/** @internal Converts operation failures and closes a terminal foreground runtime. */ +export async function runForegroundOperation( + operation: Promise, + isDisposed: () => Promise, + dispose: () => Promise, +): Promise { + try { + return await operation; + } catch (error: unknown) { + const stackError = toStackError(error); + if (await isDisposed()) { + await dispose(); + } + throw stackError; + } } export interface StackHandle extends AsyncDisposable { @@ -115,7 +58,7 @@ export interface StackHandle extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; - reloadFunctions(opts?: FunctionsConfig): Promise; + reloadFunctions(opts?: FunctionsReloadConfig): Promise; reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; ready(opts?: ReadyOptions): Promise; serviceReady(name: string, opts?: ReadyOptions): Promise; @@ -127,594 +70,6 @@ export interface StackHandle extends AsyncDisposable { logHistory(name: string, limit?: number): Promise>; } -interface ResolveConfigOptions { - readonly stackRoot?: string; - readonly runtimeRoot?: string; - readonly preferredPorts?: Partial; - readonly reservedPorts?: ReadonlySet; - readonly portAllocator?: ( - input: PortInput, - options: PortSelectionOptions, - ) => Effect.Effect; -} - -interface ResolvedRoots { - readonly cacheRoot: string; - readonly stackRoot: string; - readonly runtimeRoot: string; - readonly autoManagedPaths: ReadonlyArray; -} - -const makeTempRoot = (prefix: string) => mkdtempSync(join(shortTempPrefixRoot(), prefix)); - -const resolveRoots = (config: StackConfig, opts: ResolveConfigOptions): ResolvedRoots => { - const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); - const autoManagedPaths: string[] = []; - - const stackRoot = - opts.stackRoot ?? - config.stackRoot ?? - (() => { - const dir = makeTempRoot("sb-stack-"); - autoManagedPaths.push(dir); - return dir; - })(); - - const runtimeRoot = - opts.runtimeRoot ?? - config.runtimeRoot ?? - (() => { - const dir = makeTempRoot("sb-run-"); - autoManagedPaths.push(dir); - return dir; - })(); - - return { - cacheRoot, - stackRoot, - runtimeRoot, - autoManagedPaths, - }; -}; - -const resolveDataDir = ( - explicitDir: string | undefined, - stackRoot: string, - suffix: string, -): string => explicitDir ?? join(stackRoot, "data", suffix); - -async function readStackMetadataFile(filePath: string) { - try { - const content = await readFile(filePath, "utf8"); - return decodeStackMetadataFile(content); - } catch { - return undefined; - } -} - -async function readOwnedPorts(stackRoot: string): Promise { - const metadata = await readStackMetadataFile(join(stackRoot, "stack.json")); - return metadata?.ports; -} - -async function readReservedPorts( - projectsRoot: string, - currentStackRoot: string, -): Promise> { - const reserved = new Set(); - - let projectEntries: Array<{ isDirectory(): boolean; name: string }>; - try { - projectEntries = await readdir(projectsRoot, { withFileTypes: true }); - } catch { - return reserved; - } - - await Promise.all( - projectEntries.map(async (projectEntry) => { - if (!projectEntry.isDirectory()) { - return; - } - - const stacksRoot = join(projectsRoot, projectEntry.name, "stacks"); - let stackEntries: Array<{ isDirectory(): boolean; name: string }>; - try { - stackEntries = await readdir(stacksRoot, { withFileTypes: true }); - } catch { - return; - } - - await Promise.all( - stackEntries.map(async (stackEntry) => { - if (!stackEntry.isDirectory()) { - return; - } - - const stackRoot = join(stacksRoot, stackEntry.name); - if (stackRoot === currentStackRoot) { - return; - } - - const ports = (await readStackMetadataFile(join(stackRoot, "stack.json")))?.ports; - if (ports === undefined) { - return; - } - - for (const field of PORT_FIELDS) { - reserved.add(ports[field]); - } - }), - ); - }), - ); - - return reserved; -} - -async function readReservedPortsInStacksRoot( - stacksRoot: string, - currentStackRoot: string, -): Promise> { - const reserved = new Set(); - - let stackEntries: Array<{ isDirectory(): boolean; name: string }>; - try { - stackEntries = await readdir(stacksRoot, { withFileTypes: true }); - } catch { - return reserved; - } - - await Promise.all( - stackEntries.map(async (stackEntry) => { - if (!stackEntry.isDirectory()) { - return; - } - - const stackRoot = join(stacksRoot, stackEntry.name); - if (stackRoot === currentStackRoot) { - return; - } - - const ports = (await readStackMetadataFile(join(stackRoot, "stack.json")))?.ports; - if (ports === undefined) { - return; - } - - for (const field of PORT_FIELDS) { - reserved.add(ports[field]); - } - }), - ); - - return reserved; -} - -function resolvePostgrestConfig( - input: PostgrestConfig | undefined, - raw: PostgrestConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedPostgrestConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.postgrestPort, - adminPort: ports.postgrestAdminPort, - schemas: cfg.schemas ?? ["public", "graphql_public"], - extraSearchPath: cfg.extraSearchPath ?? ["public", "extensions"], - maxRows: cfg.maxRows ?? 1000, - version: cfg.version ?? DEFAULT_VERSIONS.postgrest, - }; -} - -function resolveAuthConfig( - input: AuthConfig | undefined, - raw: AuthConfig | false | undefined, - ports: AllocatedPorts, - apiPort: number, -): ResolvedAuthConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.authPort, - siteUrl: cfg.siteUrl ?? "http://localhost:3000", - jwtExpiry: cfg.jwtExpiry ?? 3600, - externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, - version: cfg.version ?? DEFAULT_VERSIONS.auth, - }; -} - -function resolveRealtimeConfig( - input: RealtimeConfig | undefined, - raw: RealtimeConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedRealtimeConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.realtimePort, - version: cfg.version ?? DEFAULT_VERSIONS.realtime, - tenantId: cfg.tenantId ?? "realtime-dev", - encryptionKey: cfg.encryptionKey ?? "supabaserealtime", - secretKeyBase: - cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - maxHeaderLength: cfg.maxHeaderLength ?? 4096, - }; -} - -function resolveEdgeRuntimeConfig( - input: EdgeRuntimeConfig | undefined, - raw: EdgeRuntimeConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedEdgeRuntimeConfig | false { - if (raw === false || raw?.enabled === false) return false; - const cfg = input ?? {}; - return { - enabled: cfg.enabled ?? true, - port: ports.edgeRuntimePort, - inspectorPort: ports.edgeRuntimeInspectorPort, - policy: cfg.policy ?? "per_worker", - version: cfg.version ?? DEFAULT_VERSIONS["edge-runtime"], - env: cfg.env ?? {}, - }; -} - -function resolveFunctionsConfig(config: StackConfig) { - if (config.functions === false) return false; - return { - envFile: config.functions?.envFile, - noVerifyJwt: config.functions?.noVerifyJwt ?? false, - }; -} - -function resolveStorageConfig( - input: StorageConfig | undefined, - raw: StorageConfig | false | undefined, - ports: AllocatedPorts, - opts: ResolveConfigOptions, -): ResolvedStorageConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.storagePort, - version: cfg.version ?? DEFAULT_VERSIONS.storage, - dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), - fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", - s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, - }; -} - -function resolveImgproxyConfig( - input: ImgproxyConfig | undefined, - raw: ImgproxyConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedImgproxyConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.imgproxyPort, - version: cfg.version ?? DEFAULT_VERSIONS.imgproxy, - }; -} - -function resolveMailpitConfig( - input: MailpitConfig | undefined, - raw: MailpitConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedMailpitConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.mailpitPort, - smtpPort: ports.mailpitSmtpPort, - pop3Port: ports.mailpitPop3Port, - version: cfg.version ?? DEFAULT_VERSIONS.mailpit, - adminEmail: cfg.adminEmail ?? "admin@email.com", - senderName: cfg.senderName ?? "Admin", - }; -} - -function resolvePgmetaConfig( - input: PgmetaConfig | undefined, - raw: PgmetaConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedPgmetaConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.pgmetaPort, - version: cfg.version ?? DEFAULT_VERSIONS.pgmeta, - }; -} - -function resolveStudioConfig( - input: StudioConfig | undefined, - raw: StudioConfig | false | undefined, - ports: AllocatedPorts, - apiPort: number, -): ResolvedStudioConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.studioPort, - version: cfg.version ?? DEFAULT_VERSIONS.studio, - apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, - }; -} - -function resolveAnalyticsConfig( - input: AnalyticsConfig | undefined, - raw: AnalyticsConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedAnalyticsConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.analyticsPort, - version: cfg.version ?? DEFAULT_VERSIONS.analytics, - backend: cfg.backend ?? "postgres", - apiKey: cfg.apiKey ?? "api-key", - }; -} - -function resolveVectorConfig( - input: VectorConfig | undefined, - raw: VectorConfig | false | undefined, -): ResolvedVectorConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - version: cfg.version ?? DEFAULT_VERSIONS.vector, - }; -} - -function resolvePoolerConfig( - input: PoolerConfig | undefined, - raw: PoolerConfig | false | undefined, - ports: AllocatedPorts, -): ResolvedPoolerConfig | false { - if (raw === false) return false; - const cfg = input ?? {}; - return { - port: ports.poolerPort, - apiPort: ports.poolerApiPort, - mode: cfg.mode ?? "transaction", - version: cfg.version ?? DEFAULT_VERSIONS.pooler, - tenantId: cfg.tenantId ?? "pooler-dev", - encryptionKey: cfg.encryptionKey ?? "12345678901234567890123456789032", - secretKeyBase: - cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - defaultPoolSize: cfg.defaultPoolSize ?? 20, - maxClientConn: cfg.maxClientConn ?? 100, - }; -} - -export async function resolveConfig( - input?: StackConfig, - opts: ResolveConfigOptions = {}, -): Promise { - const config = input ?? {}; - const projectDir = config.projectDir ?? process.cwd(); - const resolvedMode = config.mode ?? "auto"; - const roots = resolveRoots(config, opts); - const postgresInput = config.postgres ?? {}; - const postgrestInput = config.postgrest !== false ? (config.postgrest ?? undefined) : undefined; - const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; - const edgeRuntimeEnabled = - !(resolvedMode === "native" && config.edgeRuntime === undefined) && - config.edgeRuntime !== false && - (config.edgeRuntime?.enabled ?? true) !== false; - const realtimeEnabled = config.realtime !== undefined && config.realtime !== false; - const storageEnabled = config.storage !== undefined && config.storage !== false; - const imgproxyEnabled = config.imgproxy !== undefined && config.imgproxy !== false; - const mailpitEnabled = config.mailpit !== undefined && config.mailpit !== false; - const pgmetaEnabled = config.pgmeta !== undefined && config.pgmeta !== false; - const studioEnabled = config.studio !== undefined && config.studio !== false; - const analyticsEnabled = config.analytics !== undefined && config.analytics !== false; - const vectorEnabled = config.vector !== undefined && config.vector !== false; - const poolerEnabled = config.pooler !== undefined && config.pooler !== false; - const edgeRuntimeInput = edgeRuntimeEnabled ? (config.edgeRuntime ?? undefined) : undefined; - const realtimeInput = realtimeEnabled ? (config.realtime ?? undefined) : undefined; - const storageInput = storageEnabled ? (config.storage ?? undefined) : undefined; - const imgproxyInput = imgproxyEnabled ? (config.imgproxy ?? undefined) : undefined; - const mailpitInput = mailpitEnabled ? (config.mailpit ?? undefined) : undefined; - const pgmetaInput = pgmetaEnabled ? (config.pgmeta ?? undefined) : undefined; - const studioInput = studioEnabled ? (config.studio ?? undefined) : undefined; - const analyticsInput = analyticsEnabled ? (config.analytics ?? undefined) : undefined; - const vectorInput = vectorEnabled ? (config.vector ?? undefined) : undefined; - const poolerInput = poolerEnabled ? (config.pooler ?? undefined) : undefined; - - const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); - - const ports = await Effect.runPromise( - (opts.portAllocator ?? allocatePorts)( - { - apiPort: config.port, - dbPort: postgresInput.port, - authPort: authInput?.port, - postgrestPort: undefined, - postgrestAdminPort: undefined, - edgeRuntimePort: edgeRuntimeInput?.port, - edgeRuntimeInspectorPort: edgeRuntimeInput?.inspectorPort, - realtimePort: realtimeInput?.port, - storagePort: storageInput?.port, - imgproxyPort: imgproxyInput?.port, - mailpitPort: mailpitInput?.port, - mailpitSmtpPort: mailpitInput?.smtpPort, - mailpitPop3Port: mailpitInput?.pop3Port, - pgmetaPort: pgmetaInput?.port, - studioPort: studioInput?.port, - analyticsPort: analyticsInput?.port, - poolerPort: poolerInput?.port, - poolerApiPort: poolerInput?.apiPort, - }, - { - preferred: opts.preferredPorts, - reserved: opts.reservedPorts, - }, - ), - ).catch((error: unknown) => { - throw toStackError(error); - }); - - const jwtSecret = config.jwtSecret ?? defaultJwtSecret; - const anonJwt = generateJwt(jwtSecret, "anon"); - const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); - - return { - cacheRoot: roots.cacheRoot, - stackRoot: roots.stackRoot, - runtimeRoot: roots.runtimeRoot, - projectDir, - mode: resolvedMode, - startupMode: config.startupMode ?? "eager", - jwtSecret, - ports, - apiPort: ports.apiPort, - dbPort: ports.dbPort, - publishableKey: config.publishableKey ?? defaultPublishableKey, - secretKey: config.secretKey ?? defaultSecretKey, - functions: resolveFunctionsConfig(config), - autoManagedPaths: roots.autoManagedPaths, - anonJwt, - serviceRoleJwt, - postgres: { - port: ports.dbPort, - dataDir: postgresDataDir, - version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, - autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, - }, - postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), - auth: resolveAuthConfig(authInput, config.auth, ports, ports.apiPort), - edgeRuntime: edgeRuntimeEnabled - ? resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) - : false, - realtime: realtimeEnabled - ? resolveRealtimeConfig(realtimeInput, config.realtime, ports) - : false, - storage: storageEnabled - ? resolveStorageConfig(storageInput, config.storage, ports, { - ...opts, - stackRoot: roots.stackRoot, - }) - : false, - imgproxy: imgproxyEnabled - ? resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) - : false, - mailpit: mailpitEnabled ? resolveMailpitConfig(mailpitInput, config.mailpit, ports) : false, - pgmeta: pgmetaEnabled ? resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) : false, - studio: studioEnabled - ? resolveStudioConfig(studioInput, config.studio, ports, ports.apiPort) - : false, - analytics: analyticsEnabled - ? resolveAnalyticsConfig(analyticsInput, config.analytics, ports) - : false, - vector: vectorEnabled ? resolveVectorConfig(vectorInput, config.vector) : false, - pooler: poolerEnabled ? resolvePoolerConfig(poolerInput, config.pooler, ports) : false, - }; -} - -export type DaemonConfigInput = StackConfig & { - readonly cwd: string; - readonly name?: string; - readonly projectDir?: string; - readonly projectStateRoot?: string; -}; - -export async function resolveDaemonConfig( - input: DaemonConfigInput, - opts: Pick = {}, -): Promise { - const { cwd, name, projectDir, projectStateRoot, ...stackConfig } = input; - if (stackConfig.stackRoot !== undefined || stackConfig.runtimeRoot !== undefined) { - throw new Error("Managed daemon stacks derive stackRoot and runtimeRoot automatically"); - } - const effectiveProjectDir = projectDir ?? cwd; - const resolvedName = name ?? defaultManagedStackName(effectiveProjectDir); - const cacheRoot = stackConfig.cacheRoot ?? defaultCacheRoot(); - const stackRoot = - projectStateRoot !== undefined - ? join(projectStateRoot, "stacks", resolvedName) - : defaultManagedStackRoot(cacheRoot, effectiveProjectDir, resolvedName); - const runtimeRoot = defaultManagedRuntimeRoot(stackRoot); - const savedPorts = await readOwnedPorts(stackRoot); - const reservedPortSets = await Promise.all([ - readReservedPorts(defaultManagedProjectsRoot(cacheRoot), stackRoot), - projectStateRoot === undefined - ? Promise.resolve>(new Set()) - : readReservedPortsInStacksRoot(join(projectStateRoot, "stacks"), stackRoot), - ]); - const reservedPorts = new Set(); - for (const ports of reservedPortSets) { - for (const port of ports) { - reservedPorts.add(port); - } - } - const resolved = await resolveConfig( - { - ...stackConfig, - cacheRoot, - stackRoot, - runtimeRoot, - projectDir: effectiveProjectDir, - }, - { - stackRoot, - runtimeRoot, - preferredPorts: savedPorts ?? DEFAULT_PORTS, - reservedPorts, - portAllocator: opts.portAllocator, - }, - ); - return { - ...resolved, - name: resolvedName, - projectDir: effectiveProjectDir, - }; -} - -export const projectDaemonLayer = (opts: { - readonly cacheRoot: string; - readonly cwd: string; - readonly projectDir?: string; - readonly projectStateRoot?: string; - readonly name?: string; - readonly daemonEntryPoint: string; - readonly stackConfig?: Omit; -}): Effect.Effect< - Layer.Layer, - DaemonStartError | InvalidStackStateError | StackAlreadyRunningError, - FileSystem.FileSystem | Path.Path | UnixHttpClient -> => - daemonLayer( - { - cacheRoot: opts.cacheRoot, - cwd: opts.cwd, - projectDir: opts.projectDir, - projectStateRoot: opts.projectStateRoot, - name: opts.name, - ...opts.stackConfig, - }, - opts.daemonEntryPoint, - ); - -function possibleCleanupTargetsForConfig(config: ResolvedStackConfig): CleanupTargets { - const dockerContainerNames = [`supabase-postgres-${config.apiPort}`]; - if (config.postgrest !== false) dockerContainerNames.push(`supabase-postgrest-${config.apiPort}`); - if (config.auth !== false) dockerContainerNames.push(`supabase-auth-${config.apiPort}`); - if (config.edgeRuntime !== false) - dockerContainerNames.push(`supabase-edge-runtime-${config.apiPort}`); - if (config.realtime !== false) dockerContainerNames.push(`supabase-realtime-${config.apiPort}`); - if (config.storage !== false) dockerContainerNames.push(`supabase-storage-${config.apiPort}`); - if (config.imgproxy !== false) dockerContainerNames.push(`supabase-imgproxy-${config.apiPort}`); - if (config.mailpit !== false) dockerContainerNames.push(`supabase-mailpit-${config.apiPort}`); - if (config.pgmeta !== false) dockerContainerNames.push(`supabase-pgmeta-${config.apiPort}`); - if (config.studio !== false) dockerContainerNames.push(`supabase-studio-${config.apiPort}`); - if (config.analytics !== false) dockerContainerNames.push(`supabase-analytics-${config.apiPort}`); - if (config.vector !== false) dockerContainerNames.push(`supabase-vector-${config.apiPort}`); - if (config.pooler !== false) dockerContainerNames.push(`supabase-pooler-${config.apiPort}`); - return { dockerContainerNames }; -} - export async function createStack( config: StackConfig | undefined, platformFactory: PlatformFactory, @@ -754,21 +109,28 @@ export async function createStack( try { const services = await runtime.context(); - const localStack = await runtime.runPromise( - Effect.gen(function* () { - return yield* Stack; - }), - ); + const localStack = Context.get(services, Stack); + const lifecycle = Context.get(services, LocalStackLifecycle); const info = await runtime.runPromise(localStack.getInfo()); - const run = (effect: Effect.Effect) => - runtime.runPromise(effect).catch((error: unknown) => { - throw toStackError(error); - }); - - const gracefulDispose = async () => { - await runtime.dispose().catch(() => {}); + let disposal: Promise | undefined; + const gracefulDispose = () => { + disposal ??= runtime.dispose().catch(() => {}); + return disposal; }; + const run = (effect: Effect.Effect) => + runForegroundOperation( + runtime.runPromise(effect), + () => runtime.runPromise(lifecycle.isDisposed), + gracefulDispose, + ); + + // LocalStack owns terminality. When it disposes, close the enclosing + // runtime as well so the public API port cannot outlive the stack. + void runtime + .runPromise(lifecycle.awaitDisposed) + .then(gracefulDispose) + .catch(() => {}); const stack: StackHandle = { url: info.url, @@ -783,20 +145,8 @@ export async function createStack( restartService: (name) => run(localStack.restartService(name)), reloadFunctions: (opts) => run(localStack.reloadFunctions(opts)), reloadEdgeRuntime: (opts) => run(localStack.reloadEdgeRuntime(opts)), - ready: (opts) => { - const effect = - opts?.timeout != null - ? localStack.waitAllReady().pipe(Effect.timeout(Duration.millis(opts.timeout))) - : localStack.waitAllReady(); - return run(effect); - }, - serviceReady: (name, opts) => { - const effect = - opts?.timeout != null - ? localStack.waitReady(name).pipe(Effect.timeout(Duration.millis(opts.timeout))) - : localStack.waitReady(name); - return run(effect); - }, + ready: (opts) => run(localStack.waitAllReady(opts)), + serviceReady: (name, opts) => run(localStack.waitReady(name, opts)), getStatus: () => run(localStack.getAllStates()), getServiceStatus: (name) => run(localStack.getState(name)), statusChanges: () => Stream.toAsyncIterableWith(localStack.allStateChanges(), services), @@ -813,7 +163,7 @@ export async function createStack( } } catch (error: unknown) { await Effect.runPromise(portLease.releaseAll); - dockerForceRemove(possibleCleanupTargetsForConfig(resolved).dockerContainerNames); + dockerForceRemove(candidateCleanupTargets(resolved).dockerContainerNames); cleanupAutoManagedPaths(resolved); throw toStackError(error); } diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 68caf179c1..da7f2f2c4e 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -2,12 +2,25 @@ import { describe, expect, it } from "vitest"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ReadyOptions, StackHandle } from "./createStack.ts"; -import { resolveConfig, resolveDaemonConfig } from "./createStack.ts"; +import { candidateCleanupTargets } from "./cleanup.ts"; +import { dockerContainerName } from "./CleanupTargets.ts"; +import { runForegroundOperation, type StackHandle } from "./createStack.ts"; +import { StackReadinessError } from "./errors.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; import { DEFAULT_MANAGED_STACK_NAME, projectKeyForProjectDir } from "./paths.ts"; import { stackMetadata } from "./StackMetadata.ts"; -import type { AuthConfig, PostgresConfig, PostgrestConfig, StackConfig } from "./StackBuilder.ts"; +import type { + AuthConfig, + PostgresConfig, + PostgrestConfig, + ReadyOptions, + StackConfig, +} from "./StackConfig.ts"; +import { + resolveConfig, + resolveDaemonConfig, + sanitizeDaemonConfigInput, +} from "./StackConfigResolver.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const DEFAULT_PORTS: AllocatedPorts = { @@ -60,6 +73,60 @@ function writeStackMetadata( ); } +describe("foreground operation lifecycle", () => { + it("disposes the foreground runtime after a direct readiness timeout", async () => { + let disposeCount = 0; + const operation = Promise.reject( + new StackReadinessError({ + target: "stack", + timeoutMs: 10, + detail: "Timed out waiting for stack readiness", + }), + ); + + await expect( + runForegroundOperation( + operation, + async () => true, + async () => { + disposeCount += 1; + }, + ), + ).rejects.toMatchObject({ code: "STACK_READINESS_TIMEOUT" }); + expect(disposeCount).toBe(1); + }); + + it("disposes the foreground runtime after another terminal start failure", async () => { + let disposeCount = 0; + + await expect( + runForegroundOperation( + Promise.reject(new Error("service startup failed")), + async () => true, + async () => { + disposeCount += 1; + }, + ), + ).rejects.toMatchObject({ code: "UNKNOWN" }); + expect(disposeCount).toBe(1); + }); + + it("keeps the foreground runtime open after a non-terminal operation failure", async () => { + let disposeCount = 0; + + await expect( + runForegroundOperation( + Promise.reject(new Error("failed")), + async () => false, + async () => { + disposeCount += 1; + }, + ), + ).rejects.toMatchObject({ code: "UNKNOWN" }); + expect(disposeCount).toBe(0); + }); +}); + describe("createStack types", () => { it("StackHandle interface has expected shape", () => { const check = (_stack: StackHandle) => { @@ -129,6 +196,15 @@ describe("createStack types", () => { ); }); + it("strips function bundles from daemon configuration at runtime", () => { + const input = { + cwd: "/project", + functions: { environment: { SECRET: "must-not-cross-ipc" } }, + }; + + expect(sanitizeDaemonConfigInput(input)).toEqual({ cwd: "/project" }); + }); + it("resolveDaemonConfig prefers legacy defaults for a first named stack", async () => { await withTempCacheRoot(async (cacheRoot) => { const config = await resolveDaemonConfig({ @@ -240,6 +316,32 @@ describe("resolveConfig edge runtime defaults", () => { }); }); +describe("candidateCleanupTargets", () => { + it("derives fallback Docker identities from enabled catalog services", async () => { + const config = await resolveConfig({ + mode: "docker", + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }); + + expect(candidateCleanupTargets(config)).toEqual({ + dockerContainerNames: [ + dockerContainerName("postgres", config.apiPort), + dockerContainerName("postgrest", config.apiPort), + ], + }); + }); +}); + describe("resolveConfig startup mode", () => { it("keeps eager startup as the package default", async () => { const config = await resolveConfig(); @@ -251,3 +353,15 @@ describe("resolveConfig startup mode", () => { expect(config.startupMode).toBe("lazy"); }); }); + +describe("resolveConfig readiness policy", () => { + it("uses a finite package default", async () => { + const config = await resolveConfig(); + expect(config.readiness).toEqual({ mode: "finite", timeoutMs: 180_000 }); + }); + + it("preserves an explicit infinite policy", async () => { + const config = await resolveConfig({ readiness: { mode: "infinite" } }); + expect(config.readiness).toEqual({ mode: "infinite" }); + }); +}); diff --git a/packages/stack/src/daemon-node.ts b/packages/stack/src/daemon-node.ts index 387b2dff11..7c34ac6c70 100644 --- a/packages/stack/src/daemon-node.ts +++ b/packages/stack/src/daemon-node.ts @@ -4,9 +4,10 @@ import { createServer } from "node:http"; import { Effect, Layer } from "effect"; import { runDaemon } from "./daemon.ts"; -// Live child-process entrypoint for Node root consumers. `node.ts` resolves this module by file URL -// and passes its filesystem path to daemonLayer, so it is deliberately not a package export. The -// `knip.entry` declaration in package.json preserves this file-URL-only reachability. +// Live child-process entrypoint for Node Effect consumers. The internal Node platform adapter +// resolves this module by file URL and passes its filesystem path to daemonLayer, so it is +// deliberately not a package export. The `knip.entry` declaration in package.json preserves this +// file-URL-only reachability; see the matching note in node.ts. runDaemon( ({ apiPort, releaseApiPort }) => Layer.mergeAll( diff --git a/packages/stack/src/daemon.ts b/packages/stack/src/daemon.ts index 1171d634c5..ee42d7f0c7 100644 --- a/packages/stack/src/daemon.ts +++ b/packages/stack/src/daemon.ts @@ -1,16 +1,15 @@ import { Effect, Layer, ManagedRuntime } from "effect"; import { HttpServer } from "effect/unstable/http"; -import { - resolveDaemonConfig, - type DaemonConfigInput, - type PlatformFactory, -} from "./createStack.ts"; +import { ApiProxy } from "./ApiProxy.ts"; +import type { PlatformFactory } from "./createStack.ts"; import { DaemonServer } from "./DaemonServer.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; import { runningServiceVersionsForConfig } from "./StackMetadata.ts"; import { foregroundDaemonLayer } from "./layers.ts"; +import { LocalStackLifecycle } from "./LocalStack.ts"; import { Stack } from "./Stack.ts"; +import { resolveDaemonConfig, type DaemonConfigInput } from "./StackConfigResolver.ts"; import { StateManager, type StackState } from "./StateManager.ts"; /** Factory for creating the daemon's Unix socket HTTP server (platform-specific). */ @@ -26,12 +25,12 @@ export interface DaemonStartMessage { readonly socketPath: string; } -export interface DaemonStartedMessage { +interface DaemonStartedMessage { readonly type: "started"; readonly state: StackState; } -export interface DaemonErrorMessage { +interface DaemonErrorMessage { readonly type: "error"; readonly message: string; } @@ -49,7 +48,9 @@ export async function runDaemon( const msg = await waitForMessage(); const { socketPath } = msg; - let appRuntime: ManagedRuntime.ManagedRuntime | undefined; + let appRuntime: + | ManagedRuntime.ManagedRuntime + | undefined; let daemonRuntime: ManagedRuntime.ManagedRuntime | undefined; let portLease: PortLease | undefined; @@ -76,12 +77,14 @@ export async function runDaemon( // Build the app layer (Stack + ApiProxy) const appLayer = foregroundDaemonLayer(config, platformFactory, portLease); - appRuntime = ManagedRuntime.make(appLayer); + const localAppRuntime = ManagedRuntime.make(appLayer); + appRuntime = localAppRuntime; // Build the stack (services are started later via POST /start) - const localStack = await appRuntime.runPromise(Stack); - const info = await appRuntime.runPromise(localStack.getInfo()); - const localStateManager = await appRuntime.runPromise(StateManager); + const localStack = await localAppRuntime.runPromise(Stack); + const localStackLifecycle = await localAppRuntime.runPromise(LocalStackLifecycle); + const info = await localAppRuntime.runPromise(localStack.getInfo()); + const localStateManager = await localAppRuntime.runPromise(StateManager); // Build daemon management server on Unix socket const daemonLayer = DaemonServer.layer.pipe( @@ -89,8 +92,9 @@ export async function runDaemon( Layer.provide(daemonServerFactory(socketPath)), ); - daemonRuntime = ManagedRuntime.make(daemonLayer); - await daemonRuntime.runPromise(DaemonServer); + const localDaemonRuntime = ManagedRuntime.make(daemonLayer); + daemonRuntime = localDaemonRuntime; + await localDaemonRuntime.runPromise(DaemonServer); // Claim live state before acknowledging startup to the parent. const state: StackState = { @@ -117,8 +121,21 @@ export async function runDaemon( process.send!(response); process.disconnect?.(); - const daemon = await daemonRuntime.runPromise(DaemonServer); - await Promise.race([daemonRuntime.runPromise(daemon.awaitShutdown), waitForSignal()]); + const daemon = await localDaemonRuntime.runPromise(DaemonServer); + // Any terminal stack disposal is a whole-runtime failure: keeping the management or proxy + // servers alive would expose disposed state. Route it through the server's delayed shutdown + // signal so the request that caused disposal can flush its typed response first. + const shutdownAfterLocalStackDisposal = localAppRuntime + .runPromise(localStackLifecycle.awaitDisposed) + .then(async () => { + await localDaemonRuntime.runPromise(daemon.beginShutdown); + await localDaemonRuntime.runPromise(daemon.awaitShutdown); + }); + await Promise.race([ + localDaemonRuntime.runPromise(daemon.awaitShutdown), + shutdownAfterLocalStackDisposal, + waitForSignal(), + ]); await shutdownDaemon({ appRuntime, daemonRuntime }); process.exit(0); } catch (err) { @@ -162,7 +179,10 @@ function waitForSignal(): Promise<"SIGINT" | "SIGTERM"> { } async function shutdownDaemon(opts: { - readonly appRuntime?: ManagedRuntime.ManagedRuntime; + readonly appRuntime?: ManagedRuntime.ManagedRuntime< + Stack | StateManager | ApiProxy | LocalStackLifecycle, + never + >; readonly daemonRuntime?: ManagedRuntime.ManagedRuntime; }): Promise { await opts.daemonRuntime?.dispose().catch(() => {}); diff --git a/packages/stack/src/discovery.ts b/packages/stack/src/discovery.ts index 18de19f60f..314ec2d5ba 100644 --- a/packages/stack/src/discovery.ts +++ b/packages/stack/src/discovery.ts @@ -1,7 +1,7 @@ import { Data, Duration, Effect } from "effect"; import { FileSystem, Path } from "effect"; import { dockerForceRemove } from "./cleanup.ts"; -import { defaultManagedStackName } from "./createStack.ts"; +import { defaultManagedStackName } from "./StackConfigResolver.ts"; import { InvalidStackMetadataError, InvalidStackStateError, diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts new file mode 100644 index 0000000000..24ea594248 --- /dev/null +++ b/packages/stack/src/effect-bun.ts @@ -0,0 +1,24 @@ +// @supabase/stack/effect — Bun-bound Effect interfaces and consumer layers. + +export * from "./effect.ts"; + +import type { Layer } from "effect"; +import type { PortLease } from "./PortAllocator.ts"; +import type { Stack } from "./Stack.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { + daemonLayer as daemonLayerForPlatform, + foregroundLayer as foregroundLayerForPlatform, +} from "./layers.ts"; +import { daemonEntryPoint, platformFactory, unixHttpClientLayer } from "./platform-bun.ts"; + +export { unixHttpClientLayer }; + +export const foregroundLayer = ( + config: ResolvedStackConfig, + portLease: PortLease, +): Layer.Layer => foregroundLayerForPlatform(config, platformFactory, portLease); + +export const daemonLayer = (input: DaemonConfigInput) => + daemonLayerForPlatform(input, daemonEntryPoint); diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts new file mode 100644 index 0000000000..8159880f79 --- /dev/null +++ b/packages/stack/src/effect-node.ts @@ -0,0 +1,24 @@ +// @supabase/stack/effect — Node-bound Effect interfaces and consumer layers. + +export * from "./effect.ts"; + +import type { Layer } from "effect"; +import type { PortLease } from "./PortAllocator.ts"; +import type { Stack } from "./Stack.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { + daemonLayer as daemonLayerForPlatform, + foregroundLayer as foregroundLayerForPlatform, +} from "./layers.ts"; +import { daemonEntryPoint, platformFactory, unixHttpClientLayer } from "./platform-node.ts"; + +export { unixHttpClientLayer }; + +export const foregroundLayer = ( + config: ResolvedStackConfig, + portLease: PortLease, +): Layer.Layer => foregroundLayerForPlatform(config, platformFactory, portLease); + +export const daemonLayer = (input: DaemonConfigInput) => + daemonLayerForPlatform(input, daemonEntryPoint); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index f3efd79c4e..f1ea9b8407 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -1,5 +1,4 @@ -// @supabase/stack/effect — advanced Effect and low-level APIs. -// Platform-agnostic: pass platformFactory/daemonEntryPoint from @supabase/stack. +// Platform-agnostic Effect contracts re-exported by the conditional @supabase/stack/effect entry. export type { LogEntry } from "@supabase/process-compose"; export type { StackServiceStatus } from "./StackServiceState.ts"; @@ -13,6 +12,7 @@ export { PortConflictError, StackBuildError, StackError, + StackReadinessError, toStackError, } from "./errors.ts"; @@ -24,11 +24,7 @@ export { postgrestAssetName, } from "./Platform.ts"; -export type { BinarySpec } from "./BinaryResolver.ts"; -export { BinaryResolver } from "./BinaryResolver.ts"; - -export type { ServiceResolution } from "./resolve.ts"; -export { resolveService } from "./resolve.ts"; +export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export { prefetch } from "./prefetch.ts"; @@ -38,7 +34,6 @@ export { defaultPublishableKey, defaultSecretKey, generateJwt, - JwtGenerator, } from "./JwtGenerator.ts"; export type { @@ -57,8 +52,6 @@ export { reservePorts, } from "./PortAllocator.ts"; -export type { ProxyConfig } from "./ApiProxy.ts"; -export { ApiProxy } from "./ApiProxy.ts"; export type { AnalyticsConfig, AuthConfig, @@ -84,24 +77,31 @@ export type { ResolvedStorageConfig, ResolvedStudioConfig, ResolvedVectorConfig, + ReadinessPolicy, + ReadyOptions, StackConfig, StorageConfig, StudioConfig, VectorConfig, -} from "./StackBuilder.ts"; -export { StackBuilder } from "./StackBuilder.ts"; +} from "./StackConfig.ts"; +export { DEFAULT_STACK_READINESS_POLICY, resolveReadinessPolicy } from "./StackConfig.ts"; export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; export type { - FunctionsConfig, + FunctionsReloadConfig, FunctionsRuntimeConfig, - ResolvedFunctionsConfig, + ResolvedFunction, + ResolvedFunctionsBundle, } from "./functions.ts"; export { + clearFunctionsRuntimeConfig, configureFunctionsRuntime, + FunctionsReloadConfigSchema, functionsRuntimeConfigFileName, functionsRuntimeConfigPath, + ResolvedFunctionSchema, + ResolvedFunctionsBundleSchema, resolveFunctionsRuntimeConfig, } from "./functions.ts"; @@ -155,28 +155,14 @@ export { stackMetadata, } from "./StackMetadata.ts"; -export { DaemonServer } from "./DaemonServer.ts"; -export { RemoteStack } from "./RemoteStack.ts"; -export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -export type { - PlatformFactory, - PlatformFactoryOptions, - PlatformLayer, - PlatformServices, - ReadyOptions, - StackHandle, -} from "./createStack.ts"; +export type { ResolvedDaemonConfig } from "./StackConfig.ts"; export { - createStack, defaultManagedStackName, - projectDaemonLayer, resolveConfig, resolveDaemonConfig, -} from "./createStack.ts"; +} from "./StackConfigResolver.ts"; -export type { DaemonConfig } from "./layers.ts"; -export { connectLayer, DaemonStartError, daemonLayer, foregroundLayer } from "./layers.ts"; +export { connectLayer, DaemonStartError } from "./layers.ts"; export type { ManagedStack } from "./managed-stack.ts"; export { resolveManagedStack } from "./managed-stack.ts"; @@ -188,11 +174,3 @@ export { resolveStackSummary, stopDaemon, } from "./discovery.ts"; - -export type { - DaemonErrorMessage, - DaemonHttpServerFactory, - DaemonMessage, - DaemonStartedMessage, - DaemonStartMessage, -} from "./daemon.ts"; diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 61d8f0f41b..06b9b548ed 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -1,23 +1,76 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { Layer } from "effect"; +import * as bunRoot from "./bun.ts"; +import * as bunEffect from "./effect-bun.ts"; +import * as nodeEffect from "./effect-node.ts"; +import * as nodeRoot from "./node.ts"; +import type { StackHandle } from "./createStack.ts"; +import type { Stack } from "./Stack.ts"; +import * as testing from "./testing.ts"; -import { describe, expect, it } from "vitest"; +const INTERNAL_EFFECT_EXPORTS = [ + "ApiProxy", + "BinaryResolver", + "DaemonServer", + "JwtGenerator", + "RemoteStack", + "StackBuilder", + "UnixHttpClient", + "createStack", + "projectDaemonLayer", +] as const; describe("@supabase/stack entrypoints", () => { - it("ships conditional root exports and keeps only the effect subpath", () => { + it("declares only intentional package entrypoints", () => { const srcDir = dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(readFileSync(join(srcDir, "../package.json"), "utf8")) as { readonly exports: Record>; + readonly knip: { readonly entry: ReadonlyArray }; }; - expect(packageJson.exports["."]).toEqual({ - bun: "./src/bun.ts", - default: "./src/node.ts", + expect(packageJson.exports).toEqual({ + ".": { + bun: "./src/bun.ts", + default: "./src/node.ts", + }, + "./effect": { + bun: "./src/effect-bun.ts", + default: "./src/effect-node.ts", + }, + "./testing": "./src/testing.ts", + "./daemon-bun": "./src/daemon-bun.ts", }); - expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); - expect(packageJson.exports["./bun"]).toBeUndefined(); - expect(packageJson.exports["./node"]).toBeUndefined(); + expect(packageJson.exports["./daemon-node"]).toBeUndefined(); expect(packageJson.exports["./internals"]).toBeUndefined(); + expect(packageJson.knip.entry).toContain("src/daemon-node.ts"); + }); + + it("keeps the root runtime surface Promise-only", () => { + expect(Object.keys(nodeRoot).sort()).toEqual(["createStack", "prefetch"]); + expect(Object.keys(bunRoot).sort()).toEqual(["createStack", "prefetch"]); + expectTypeOf(nodeRoot.createStack).returns.toEqualTypeOf>(); + expectTypeOf(bunRoot.createStack).returns.toEqualTypeOf>(); + }); + + it("binds consumer Effect layers without exposing implementation tags", () => { + expectTypeOf(nodeEffect.foregroundLayer).returns.toEqualTypeOf>(); + expectTypeOf(bunEffect.foregroundLayer).returns.toEqualTypeOf>(); + + for (const entrypoint of [nodeEffect, bunEffect]) { + expect(entrypoint).toHaveProperty("connectLayer"); + expect(entrypoint).toHaveProperty("daemonLayer"); + expect(entrypoint).toHaveProperty("foregroundLayer"); + expect(entrypoint).toHaveProperty("unixHttpClientLayer"); + for (const name of INTERNAL_EFFECT_EXPORTS) { + expect(entrypoint).not.toHaveProperty(name); + } + } + }); + + it("isolates consumer test seams in the testing entry", () => { + expect(Object.keys(testing).sort()).toEqual(["DaemonServer", "UnixHttpClient"]); }); }); diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 1da68cdd63..937d3330db 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -31,6 +31,12 @@ export class StackNotRunningError extends Data.TaggedError("StackNotRunningError readonly phase: string; }> {} +export class StackReadinessError extends Data.TaggedError("StackReadinessError")<{ + readonly target: string; + readonly timeoutMs: number; + readonly detail: string; +}> {} + export class PortConflictError extends Data.TaggedError("PortConflictError")<{ readonly port: number; readonly service: string; @@ -71,6 +77,12 @@ export function toStackError(err: unknown): StackError { message: taggedMessage, cause: err, }); + case "StackReadinessError": + return new StackError({ + code: "STACK_READINESS_TIMEOUT", + message: taggedMessage, + cause: err, + }); case "BinaryNotFoundError": return new StackError({ code: "BINARY_NOT_FOUND", diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index 527a163b65..478790fe6e 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -1,25 +1,133 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; -import { - inferFunctionsManifest, - loadProjectConfig, - loadProjectEnvironment, - resolveProjectSubtree, - type ResolvedFunctionConfig, -} from "@supabase/config"; -import { Effect, FileSystem, Path, Redacted } from "effect"; -import type { ResolvedStackConfig } from "./StackBuilder.ts"; - -export interface FunctionsConfig { - readonly envFile?: string; - readonly noVerifyJwt?: boolean; +import { existsSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; + +const absolutePath = Schema.String.check( + Schema.makeFilter((value) => + isAbsolute(value) ? undefined : { path: [], issue: "Expected an absolute path" }, + ), +); + +const environment = Schema.Record(Schema.String, Schema.String); + +export const ResolvedFunctionSchema = Schema.Struct({ + name: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/)), + verifyJWT: Schema.Boolean, + entrypointPath: absolutePath, + importMapPath: Schema.NullOr(absolutePath), + staticFiles: Schema.Array(absolutePath), + env: environment, +}); + +export interface ResolvedFunction extends Schema.Schema.Type {} + +/** + * Project-owned Edge Functions input. Every path and environment reference is + * resolved before the bundle crosses into the stack package. + * + * `env` contains values shared by every function. A function's own `env` + * overrides matching shared values when its worker is created. + */ +export const ResolvedFunctionsBundleSchema = Schema.Struct({ + env: environment, + functions: Schema.Array(ResolvedFunctionSchema), +}).check( + Schema.makeFilter((bundle) => { + const names = new Set(); + for (let index = 0; index < bundle.functions.length; index += 1) { + const name = bundle.functions[index]?.name; + if (name !== undefined && names.has(name)) { + return { + path: ["functions", index, "name"], + issue: `Duplicate function name: ${name}`, + }; + } + if (name !== undefined) { + names.add(name); + } + } + return undefined; + }), +); + +export interface ResolvedFunctionsBundle extends Schema.Schema.Type< + typeof ResolvedFunctionsBundleSchema +> {} + +function isWithinPath(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate); + return ( + relativePath === "" || + (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) + ); +} + +function nearestExistingAncestor(candidate: string): string | undefined { + let current = resolve(candidate); + while (!existsSync(current)) { + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } + return current; } -export interface ResolvedFunctionsConfig { - readonly envFile?: string; - readonly noVerifyJwt: boolean; +function isWithinProjectDir(projectDir: string, candidate: string): boolean { + const resolvedProjectDir = resolve(projectDir); + if (!isWithinPath(resolvedProjectDir, candidate)) return false; + + const existingCandidate = nearestExistingAncestor(candidate); + if (existingCandidate === undefined) return false; + try { + return isWithinPath(realpathSync(resolvedProjectDir), realpathSync(existingCandidate)); + } catch { + return false; + } } +/** + * Docker mounts `projectDir` at the same absolute path as the host. Keeping all + * referenced files below that root gives native and Docker runtimes the same + * bundle contract, including after a reload. + */ +export const resolvedFunctionsBundleSchemaForProject = (projectDir: string) => + ResolvedFunctionsBundleSchema.check( + Schema.makeFilter((bundle) => { + for (let index = 0; index < bundle.functions.length; index += 1) { + const fn = bundle.functions[index]; + if (fn === undefined) continue; + + const paths = [ + { field: "entrypointPath", value: fn.entrypointPath }, + ...(fn.importMapPath === null + ? [] + : [{ field: "importMapPath", value: fn.importMapPath }]), + ...fn.staticFiles.map((value, staticIndex) => ({ + field: `staticFiles.${staticIndex}`, + value, + })), + ]; + const outsideProject = paths.find(({ value }) => !isWithinProjectDir(projectDir, value)); + if (outsideProject !== undefined) { + return { + path: ["functions", index, outsideProject.field], + issue: "Function paths must be within projectDir", + }; + } + } + return undefined; + }), + ); + +export const FunctionsReloadConfigSchema = Schema.Struct({ + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), +}); + +export interface FunctionsReloadConfig extends Schema.Schema.Type< + typeof FunctionsReloadConfigSchema +> {} + export interface FunctionsRuntimeConfig { readonly functionsUrl: string; readonly supabaseUrl: string; @@ -34,8 +142,9 @@ export interface FunctionsRuntimeConfig { { readonly verifyJWT: boolean; readonly entrypointPath: string; - readonly importMapPath: string; + readonly importMapPath: string | null; readonly staticFiles: ReadonlyArray; + readonly env: Readonly>; } > >; @@ -55,142 +164,15 @@ export function functionsRuntimeConfigPath(runtimeRoot: string): string { return join(edgeRuntimeWorkspaceDir(runtimeRoot), functionsRuntimeConfigFileName); } -function reveal(value: string | Redacted.Redacted): string { - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} - -function absolutizeProjectPath(projectDir: string, relativePath: string): string { - if (relativePath.length === 0) { - return ""; - } - - const withoutDotSlash = relativePath.startsWith("./") ? relativePath.slice(2) : relativePath; - return isAbsolute(withoutDotSlash) - ? withoutDotSlash - : join(projectDir, "supabase", withoutDotSlash); -} - -function parseDotEnv(contents: string): Record { - const env: Record = {}; - const lines = contents.replace(/\r\n?/g, "\n").split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === "" || trimmed.startsWith("#")) { - continue; - } - - const equals = line.indexOf("="); - if (equals === -1) { - continue; - } - - const key = line - .slice(0, equals) - .trim() - .replace(/^export\s+/, ""); - let value = line.slice(equals + 1).trim(); - const quote = value[0]; - if ( - (quote === '"' || quote === "'" || quote === "`") && - value.endsWith(quote) && - value.length >= 2 - ) { - value = value.slice(1, -1); - } - if (quote === '"') { - value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); - } - env[key] = value; - } - - return env; -} - -function loadEnvFile(path: string): Record { - try { - return parseDotEnv(readFileSync(path, "utf8")); - } catch { - return {}; - } -} - -const resolveFunctionsProjectConfig = Effect.fnUntraced(function* (projectDir: string) { - const projectEnv = yield* loadProjectEnvironment({ cwd: projectDir, baseEnv: process.env }); - const loadedConfig = yield* loadProjectConfig(projectDir); - if (projectEnv === null || loadedConfig === null) { - return undefined; - } - - const resolvedFunctions = yield* resolveProjectSubtree( - loadedConfig.config.functions, - projectEnv, - "functions", - ); - - return { - ...loadedConfig.config, - functions: Object.fromEntries( - Object.entries(resolvedFunctions).map(([slug, config]) => [ - slug, - { - ...config, - entrypoint: reveal(config.entrypoint), - import_map: reveal(config.import_map), - static_files: config.static_files.map((path) => reveal(path)), - env: Object.fromEntries( - Object.entries(config.env).map(([name, value]) => [name, reveal(value)]), - ), - }, - ]), - ), - }; -}); - -function functionToRuntimeConfig( - projectDir: string, - noVerifyJwt: boolean, - config: ResolvedFunctionConfig, -) { - return { - verifyJWT: noVerifyJwt ? false : config.verify_jwt, - entrypointPath: absolutizeProjectPath(projectDir, config.entrypoint), - importMapPath: absolutizeProjectPath(projectDir, config.import_map), - staticFiles: config.static_files.map((path) => absolutizeProjectPath(projectDir, path)), - }; -} - -export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( +export function resolveFunctionsRuntimeConfig( stackConfig: ResolvedStackConfig, runtimeHost: FunctionsRuntimeHost, -) { - const functionsConfig = stackConfig.functions; - if (functionsConfig === false || stackConfig.edgeRuntime === false) { - return undefined; - } - - const projectConfig = yield* resolveFunctionsProjectConfig(stackConfig.projectDir); - const manifest = yield* inferFunctionsManifest({ - cwd: stackConfig.projectDir, - ...(projectConfig === undefined ? {} : { config: projectConfig }), - }); - const enabledManifest = Object.entries(manifest).filter(([, config]) => config.enabled); - if (enabledManifest.length === 0) { + bundle: ResolvedFunctionsBundle | undefined, +): FunctionsRuntimeConfig | undefined { + if (bundle === undefined || bundle.functions.length === 0 || stackConfig.edgeRuntime === false) { return undefined; } - const functionEnv = Object.fromEntries( - enabledManifest.flatMap(([, config]) => Object.entries(config.env)), - ); - const envFilePath = - functionsConfig.envFile === undefined - ? join(stackConfig.projectDir, "supabase", "functions", ".env") - : resolve(stackConfig.projectDir, functionsConfig.envFile); - const env = { - ...loadEnvFile(envFilePath), - ...functionEnv, - }; - return { functionsUrl: `http://127.0.0.1:${stackConfig.apiPort}/functions/v1`, supabaseUrl: `http://${runtimeHost.hostname}:${stackConfig.apiPort}`, @@ -198,15 +180,21 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( publishableKey: stackConfig.publishableKey, secretKey: stackConfig.secretKey, jwtSecret: stackConfig.jwtSecret, - env, + env: bundle.env, functions: Object.fromEntries( - enabledManifest.map(([slug, config]) => [ - slug, - functionToRuntimeConfig(stackConfig.projectDir, functionsConfig.noVerifyJwt, config), + bundle.functions.map((fn) => [ + fn.name, + { + verifyJWT: fn.verifyJWT, + entrypointPath: fn.entrypointPath, + importMapPath: fn.importMapPath, + staticFiles: fn.staticFiles, + env: fn.env, + }, ]), ), - } satisfies FunctionsRuntimeConfig; -}); + }; +} const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( runtimeRoot: string, @@ -215,20 +203,41 @@ const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const filePath = functionsRuntimeConfigPath(runtimeRoot); - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, `${JSON.stringify(config, null, 2)}\n`); + const directory = path.dirname(filePath); + const temporaryPath = `${filePath}.tmp-${crypto.randomUUID()}`; + + yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); + yield* Effect.gen(function* () { + yield* fs.writeFileString(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + yield* fs.chmod(temporaryPath, 0o600); + yield* fs.rename(temporaryPath, filePath); + }).pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore))); }); -const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { +export const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(functionsRuntimeConfigPath(runtimeRoot)).pipe(Effect.ignore); + const filePath = functionsRuntimeConfigPath(runtimeRoot); + const directory = yield* Path.Path.pipe(Effect.map((path) => path.dirname(filePath))); + + yield* fs.remove(filePath).pipe(Effect.ignore); + + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry.startsWith(`${functionsRuntimeConfigFileName}.tmp-`)), + (entry) => fs.remove(join(directory, entry)).pipe(Effect.ignore), + { discard: true }, + ); }); export const configureFunctionsRuntime = Effect.fnUntraced(function* ( stackConfig: ResolvedStackConfig, runtimeHost: FunctionsRuntimeHost, + bundle: ResolvedFunctionsBundle | undefined, ) { - const runtimeConfig = yield* resolveFunctionsRuntimeConfig(stackConfig, runtimeHost); + const runtimeConfig = resolveFunctionsRuntimeConfig(stackConfig, runtimeHost, bundle); if (runtimeConfig === undefined) { yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); } else { diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 56ead117fb..46f9298c83 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,16 +1,19 @@ 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 { mkdtempSync, symlinkSync } from "node:fs"; +import { readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect } from "effect"; -import { resolveConfig } from "./createStack.ts"; +import { Effect, Schema } from "effect"; +import { resolveConfig } from "./StackConfigResolver.ts"; import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; import { + clearFunctionsRuntimeConfig, configureFunctionsRuntime, functionsRuntimeConfigPath, + ResolvedFunctionsBundleSchema, resolveFunctionsRuntimeConfig, + type ResolvedFunctionsBundle, } from "./functions.ts"; import { verifyRequest } from "./services/edge-runtime-main.ts"; @@ -18,42 +21,20 @@ function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); } -async function writeProject(cwd: string) { - await mkdir(join(cwd, "supabase", "functions", "hello-world"), { recursive: true }); - await mkdir(join(cwd, "supabase", "functions", "disabled-function"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "functions", "hello-world", "index.ts"), - "Deno.serve(() => Response.json({ ok: true }));\n", - ); - await writeFile( - join(cwd, "supabase", "functions", "disabled-function", "index.ts"), - "Deno.serve(() => Response.json({ disabled: true }));\n", - ); - await writeFile( - join(cwd, "supabase", ".env"), - "CONFIG_ONLY=from-project-env\nSHARED=from-project-env\n", - ); - await writeFile( - join(cwd, "supabase", "functions", ".env"), - "FILE_ONLY=from-functions-env\nSHARED=from-functions-env\n", - ); - await writeFile( - join(cwd, "supabase", "config.json"), - JSON.stringify({ - functions: { - "hello-world": { - verify_jwt: true, - env: { - CONFIG_ONLY: "env(CONFIG_ONLY)", - SHARED: "env(SHARED)", - }, - }, - "disabled-function": { - enabled: false, - }, +function makeBundle(root: string): ResolvedFunctionsBundle { + return { + env: { SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }, + functions: [ + { + name: "hello-world", + verifyJWT: true, + entrypointPath: join(root, "functions", "hello-world", "index.ts"), + importMapPath: null, + staticFiles: [join(root, "functions", "hello-world", "assets", "*")], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, }, - }), - ); + ], + }; } function jwtWithInvalidSignature(algorithm?: string): string { @@ -101,95 +82,152 @@ const authFailureCases = [ ]; describe("stack Functions runtime config", () => { - it.live("auto-detects enabled functions from projectDir", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); - - expect(config).toBeDefined(); - expect(Object.keys(config!.functions)).toEqual(["hello-world"]); - expect(config!.functions["hello-world"]).toEqual({ - verifyJWT: true, - entrypointPath: join(cwd, "supabase", "functions", "hello-world", "index.ts"), - importMapPath: "", - staticFiles: [], - }); - expect(config!.env).toMatchObject({ - FILE_ONLY: "from-functions-env", - CONFIG_ONLY: "from-project-env", - SHARED: "from-project-env", - }); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), + it("projects an explicit bundle without project discovery", async () => { + const root = makeTempProject(); + const stackConfig = await resolveConfig({ projectDir: root, functions: makeBundle(root) }); + const config = resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + makeBundle(root), ); + + expect(config?.env).toEqual({ SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }); + expect(config?.functions["hello-world"]).toEqual({ + verifyJWT: true, + entrypointPath: join(root, "functions", "hello-world", "index.ts"), + importMapPath: null, + staticFiles: [join(root, "functions", "hello-world", "assets", "*")], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, + }); + + await rm(root, { recursive: true, force: true }); }); - it.live("supports explicit env files and disabling JWT verification", () => { - const cwd = makeTempProject(); + it("validates paths, import maps, and unique function names", async () => { + const decode = Schema.decodeUnknownSync(ResolvedFunctionsBundleSchema); + const root = makeTempProject(); + const bundle = makeBundle(root); - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - yield* Effect.promise(() => writeFile(join(cwd, "custom.env"), "FILE_ONLY=custom\n")); - const stackConfig = yield* Effect.promise(() => - resolveConfig({ - projectDir: cwd, - functions: { - envFile: "custom.env", - noVerifyJwt: true, - }, - }), - ); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); + expect(decode(bundle).functions[0]?.importMapPath).toBeNull(); + expect(() => + decode({ + ...bundle, + functions: [{ ...bundle.functions[0], entrypointPath: "./index.ts" }], + }), + ).toThrow("Expected an absolute path"); + expect(() => + decode({ + ...bundle, + functions: [bundle.functions[0], bundle.functions[0]], + }), + ).toThrow("Duplicate function name: hello-world"); - expect(config!.env.FILE_ONLY).toBe("custom"); - expect(config!.functions["hello-world"]?.verifyJWT).toBe(false); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), - ); + await rm(root, { recursive: true, force: true }); }); - it.live("keeps placeholder mode when Functions are disabled", () => { - const cwd = makeTempProject(); + it("validates explicit bundles at config resolution", async () => { + const root = makeTempProject(); + const bundle = makeBundle(root); - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => - resolveConfig({ projectDir: cwd, functions: false }), - ); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); + await expect( + resolveConfig({ + projectDir: root, + functions: { + ...bundle, + functions: [bundle.functions[0]!, bundle.functions[0]!], + }, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + }); + await expect( + resolveConfig({ + projectDir: join(root, "project"), + functions: bundle, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + }); - expect(config).toBeUndefined(); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), + await rm(root, { recursive: true, force: true }); + }); + + it("rejects bundle paths that escape projectDir through a symlink", async () => { + const root = makeTempProject(); + const outside = makeTempProject(); + const bundle = makeBundle(root); + symlinkSync( + outside, + join(root, "linked-outside"), + process.platform === "win32" ? "junction" : "dir", ); + + await expect( + resolveConfig({ + projectDir: root, + functions: { + ...bundle, + functions: [ + { + ...bundle.functions[0]!, + entrypointPath: join(root, "linked-outside", "index.ts"), + }, + ], + }, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + }); + + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + }); + + it("keeps placeholder mode when no functions are supplied", async () => { + const stackConfig = await resolveConfig({ functions: false }); + + expect( + resolveFunctionsRuntimeConfig(stackConfig, { hostname: "127.0.0.1" }, undefined), + ).toBeUndefined(); + expect( + resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + { + env: {}, + functions: [], + }, + ), + ).toBeUndefined(); }); - it.live("writes generated runtime config into the stack runtime directory", () => { + it.live("atomically writes restrictive ephemeral config and removes it", () => { const cwd = makeTempProject(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); - yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }); - const written = JSON.parse( - yield* Effect.promise(() => - readFile(functionsRuntimeConfigPath(stackConfig.runtimeRoot), "utf8"), - ), - ) as { functions: Record }; + const bundle = makeBundle(cwd); + const stackConfig = yield* Effect.promise(() => + resolveConfig({ projectDir: cwd, runtimeRoot: cwd, functions: bundle }), + ); + yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); + const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); + const written = JSON.parse(yield* Effect.promise(() => readFile(filePath, "utf8"))) as { + functions: Record; + }; expect(Object.keys(written.functions)).toEqual(["hello-world"]); + expect((yield* Effect.promise(() => stat(filePath))).mode & 0o777).toBe(0o600); + expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([ + "functions-runtime-config.json", + ]); + + yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); + expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([]); }).pipe( Effect.provide(BunServices.layer), Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 087b40503e..3969233f6f 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -14,15 +14,21 @@ export type { PostgresConfig, PostgrestConfig, RealtimeConfig, + ReadinessPolicy, + ReadyOptions, StackConfig, StorageConfig, StudioConfig, VectorConfig, -} from "./StackBuilder.ts"; +} from "./StackConfig.ts"; export type { ServiceName, VersionManifest } from "./versions.ts"; -export type { ServiceResolution } from "./resolve.ts"; +export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; -export type { ReadyOptions, StackHandle } from "./createStack.ts"; -export type { FunctionsConfig, FunctionsRuntimeConfig } from "./functions.ts"; -export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; +export type { StackHandle } from "./createStack.ts"; +export type { + FunctionsReloadConfig, + FunctionsRuntimeConfig, + ResolvedFunction, + ResolvedFunctionsBundle, +} from "./functions.ts"; diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index 34f3ea0413..2edf127344 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -6,14 +6,13 @@ import { FileSystem, Path } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; import { BinaryResolver } from "./BinaryResolver.ts"; -import type { DaemonConfigInput, PlatformFactory } from "./createStack.ts"; +import type { PlatformFactory } from "./createStack.ts"; import type { DaemonMessage, DaemonStartMessage } from "./daemon.ts"; import { DaemonMessageSchema } from "./DaemonProtocol.ts"; import type { PortLease } from "./PortAllocator.ts"; import { RemoteStack } from "./RemoteStack.ts"; -import { StackServiceActivator } from "./ServiceActivation.ts"; import { Stack } from "./Stack.ts"; -import { StackLifecycleCoordinator } from "./StackLifecycleCoordinator.ts"; +import { LocalStackLifecycle, localStackLayer } from "./LocalStack.ts"; import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; import { StackPreparation } from "./StackPreparation.ts"; import { @@ -23,7 +22,9 @@ import { StateManager, singleStackStateManagerPaths, } from "./StateManager.ts"; -import { StackBuilder, type ResolvedStackConfig } from "./StackBuilder.ts"; +import { StackBuilder } from "./StackBuilder.ts"; +import type { ResolvedDaemonConfig, ResolvedStackConfig } from "./StackConfig.ts"; +import { sanitizeDaemonConfigInput, type DaemonConfigInput } from "./StackConfigResolver.ts"; import { UnixHttpClient } from "./UnixHttpClient.ts"; import { resolveManagedStack } from "./managed-stack.ts"; import { @@ -44,7 +45,7 @@ export const foregroundLayer = ( config: ResolvedStackConfig, platformFactory: PlatformFactory, portLease: PortLease, -): Layer.Layer => { +): Layer.Layer => { const platform = platformFactory({ apiPort: config.apiPort, releaseApiPort: portLease.release(["apiPort"]), @@ -54,18 +55,11 @@ export const foregroundLayer = ( Layer.provide(FetchHttpClient.layer), ); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(binaryResolverLayer)); - const coordinatorLayer = StackLifecycleCoordinator.layer(config, portLease).pipe( + const stackLayer = localStackLayer(config, portLease).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(StackMetadataPersistence.noop), ); - const stackLayer = Stack.layer(config); - const serviceActivatorLayer = Layer.effect( - StackServiceActivator, - Effect.map(StackLifecycleCoordinator, (coordinator) => ({ - activate: coordinator.activateService, - })), - ); const proxyConfig: ProxyConfig = { listenPort: config.apiPort, @@ -86,14 +80,10 @@ export const foregroundLayer = ( }; const apiProxyLayer = ApiProxy.layer(proxyConfig).pipe( Layer.provide(FetchHttpClient.layer), - Layer.provide(serviceActivatorLayer), + Layer.provide(stackLayer), ); - return Layer.mergeAll(stackLayer, apiProxyLayer).pipe( - Layer.provide(coordinatorLayer), - Layer.provide(platform), - Layer.orDie, - ); + return Layer.mergeAll(stackLayer, apiProxyLayer).pipe(Layer.provide(platform), Layer.orDie); }; // --------------------------------------------------------------------------- @@ -108,16 +98,11 @@ export class DaemonStartError extends Data.TaggedError("DaemonStartError")<{ // Daemon-backed mode // --------------------------------------------------------------------------- -export interface DaemonConfig extends ResolvedStackConfig { - readonly name: string; - readonly projectDir: string; -} - export const foregroundDaemonLayer = ( - config: DaemonConfig, + config: ResolvedDaemonConfig, platformFactory: PlatformFactory, portLease: PortLease, -): Layer.Layer => { +): Layer.Layer => { const platform = platformFactory({ apiPort: config.apiPort, releaseApiPort: portLease.release(["apiPort"]), @@ -143,16 +128,6 @@ export const foregroundDaemonLayer = ( anonJwt: config.anonJwt, serviceRoleJwt: config.serviceRoleJwt, }; - const serviceActivatorLayer = Layer.effect( - StackServiceActivator, - Effect.map(StackLifecycleCoordinator, (coordinator) => ({ - activate: coordinator.activateService, - })), - ); - const apiProxyLayer = ApiProxy.layer(proxyConfig).pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(serviceActivatorLayer), - ); const stateManagerLayer = StateManager.make( singleStackStateManagerPaths(config.stackRoot, config.runtimeRoot, config.name), ); @@ -160,15 +135,17 @@ export const foregroundDaemonLayer = ( const metadataPersistenceLayer = StackMetadataPersistence.fromStateManager(config.name).pipe( Layer.provide(stateManagerLayer), ); - const coordinatorLayer = StackLifecycleCoordinator.layer(config, portLease).pipe( + const stackLayer = localStackLayer(config, portLease).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(metadataPersistenceLayer), ); - const stackLayer = Stack.layer(config); + const apiProxyLayer = ApiProxy.layer(proxyConfig).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(stackLayer), + ); return Layer.mergeAll(stackLayer, apiProxyLayer, stateManagerLayer).pipe( - Layer.provide(coordinatorLayer), Layer.provide(platform), Layer.orDie, ); @@ -192,21 +169,22 @@ export const daemonLayer = ( FileSystem.FileSystem | Path.Path | UnixHttpClient > => Effect.gen(function* () { - if (input.stackRoot !== undefined || input.runtimeRoot !== undefined) { + const daemonInput = sanitizeDaemonConfigInput(input); + if (daemonInput.stackRoot !== undefined || daemonInput.runtimeRoot !== undefined) { return yield* new DaemonStartError({ message: "Managed daemon stacks derive stackRoot and runtimeRoot automatically", }); } - const projectDir = input.projectDir ?? input.cwd; - const name = input.name ?? DEFAULT_MANAGED_STACK_NAME; - const cacheRoot = input.cacheRoot ?? defaultCacheRoot(); + const projectDir = daemonInput.projectDir ?? daemonInput.cwd; + const name = daemonInput.name ?? DEFAULT_MANAGED_STACK_NAME; + const cacheRoot = daemonInput.cacheRoot ?? defaultCacheRoot(); const stackRoot = - input.projectStateRoot !== undefined - ? join(input.projectStateRoot, "stacks", name) + daemonInput.projectStateRoot !== undefined + ? join(daemonInput.projectStateRoot, "stacks", name) : defaultManagedStackRoot(cacheRoot, projectDir, name); const runtimeRoot = defaultManagedRuntimeRoot(stackRoot); const config: DaemonConfigInput = { - ...input, + ...daemonInput, cacheRoot, projectDir, name, diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index 8feb036db6..8be38e7ce1 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -1,145 +1,23 @@ import { NodeServices } from "@effect/platform-node"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { createServer } from "node:http"; -import * as Http from "node:http"; -import { Readable } from "node:stream"; -import { fileURLToPath } from "node:url"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { - createStack as createStackCore, - type PlatformFactory, - type StackHandle, -} from "./createStack.ts"; +import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; import { prefetch as prefetchEffect, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; import { defaultCacheRoot } from "./paths.ts"; +import { platformFactory } from "./platform-node.ts"; import { StackPreparation } from "./StackPreparation.ts"; -import type { StackConfig } from "./StackBuilder.ts"; -import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -const mergeBodyHeaders = ( - headersInit: RequestInit["headers"] | undefined, - bodyHeaders: Headers, -): Headers => { - const headers = new Headers(headersInit); - for (const [key, value] of bodyHeaders.entries()) { - if (!headers.has(key)) { - headers.set(key, value); - } - } - return headers; -}; - -const toOutgoingHeaders = (headers: Headers): Http.OutgoingHttpHeaders => - Object.fromEntries(headers.entries()); - -const toResponseHeaders = (headers: Http.IncomingHttpHeaders): Headers => { - const responseHeaders = new Headers(); - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - responseHeaders.append(key, item); - } - continue; - } - responseHeaders.set(key, value); - } - return responseHeaders; -}; - -const encodeRequest = async ( - init: RequestInit | undefined, -): Promise<{ - readonly body: Uint8Array | undefined; - readonly headers: Http.OutgoingHttpHeaders; -}> => { - if (init?.body == null) { - return { - body: undefined, - headers: toOutgoingHeaders(new Headers(init?.headers)), - }; - } - - const bodyResponse = new Response(init.body); - const headers = mergeBodyHeaders(init.headers, bodyResponse.headers); - return { - body: new Uint8Array(await bodyResponse.arrayBuffer()), - headers: toOutgoingHeaders(headers), - }; -}; - -const toWebResponse = (response: Http.IncomingMessage): Response => - new Response( - response.statusCode === 204 || response.statusCode === 304 ? null : Readable.toWeb(response), - { - status: response.statusCode ?? 200, - statusText: response.statusMessage ?? "", - headers: toResponseHeaders(response.headers), - }, - ); - -export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { - request: (socketPath, path, init) => - Effect.tryPromise({ - try: async () => { - const { body, headers } = await encodeRequest(init); - return await new Promise((resolve, reject) => { - const request = Http.request( - { - socketPath, - path, - method: init?.method ?? "GET", - headers, - signal: init?.signal ?? undefined, - }, - (response) => { - resolve(toWebResponse(response)); - }, - ); - - request.on("error", reject); - request.end(body); - }); - }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), - }), -}); - -// --------------------------------------------------------------------------- -// Platform values — for use with Effect layer factories -// --------------------------------------------------------------------------- - -/** Node platform factory for use with foregroundLayer / daemonLayer. */ -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - NodeServices.layer, - Layer.unwrap( - releaseApiPort.pipe( - Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), - ), - ), - ); +import type { StackConfig } from "./StackConfig.ts"; /** - * Path to the Node daemon entry point for use with daemonLayer. - * - * `daemon-node.ts` is intentionally reached by this file URL instead of a package export. Keep the - * matching `knip.entry` in package.json when changing this path; static import analysis cannot see - * the child-process entrypoint. + * The Node daemon bootstrap is deliberately not exported from the package. The conditional Effect + * entry resolves `daemon-node.ts` by file URL through the internal platform adapter. Keep + * `src/daemon-node.ts` in package.json's `knip.entry` list: static imports cannot see that fork target. */ -export const daemonEntryPoint: string = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); - -// --------------------------------------------------------------------------- -// Promise API — convenience wrappers for non-Effect consumers -// --------------------------------------------------------------------------- export async function createStack(config?: StackConfig): Promise { return createStackCore(config, platformFactory); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts new file mode 100644 index 0000000000..96d6252165 --- /dev/null +++ b/packages/stack/src/platform-bun.ts @@ -0,0 +1,30 @@ +import { BunServices } from "@effect/platform-bun"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import type { PlatformFactory } from "./createStack.ts"; +import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; + +interface BunUnixRequestInit extends RequestInit { + readonly unix: string; +} + +export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { + request: (socketPath, path, init) => + Effect.tryPromise({ + try: () => { + const requestInit: BunUnixRequestInit = { ...init, unix: socketPath }; + return fetch(`http://localhost${path}`, requestInit); + }, + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + }), +}); + +export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => + Layer.mergeAll( + BunServices.layer, + Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), + ); + +/** Internal source-mode child target. Compiled CLI dispatch still uses the daemon-bun export. */ +export const daemonEntryPoint = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts new file mode 100644 index 0000000000..bdcc9c85b3 --- /dev/null +++ b/packages/stack/src/platform-node.ts @@ -0,0 +1,109 @@ +import { NodeServices } from "@effect/platform-node"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { createServer } from "node:http"; +import * as Http from "node:http"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import type { PlatformFactory } from "./createStack.ts"; +import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; + +const mergeBodyHeaders = ( + headersInit: RequestInit["headers"] | undefined, + bodyHeaders: Headers, +): Headers => { + const headers = new Headers(headersInit); + for (const [key, value] of bodyHeaders.entries()) { + if (!headers.has(key)) { + headers.set(key, value); + } + } + return headers; +}; + +const toOutgoingHeaders = (headers: Headers): Http.OutgoingHttpHeaders => + Object.fromEntries(headers.entries()); + +const toResponseHeaders = (headers: Http.IncomingHttpHeaders): Headers => { + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) responseHeaders.append(key, item); + continue; + } + responseHeaders.set(key, value); + } + return responseHeaders; +}; + +const encodeRequest = async ( + init: RequestInit | undefined, +): Promise<{ + readonly body: Uint8Array | undefined; + readonly headers: Http.OutgoingHttpHeaders; +}> => { + if (init?.body == null) { + return { + body: undefined, + headers: toOutgoingHeaders(new Headers(init?.headers)), + }; + } + + const bodyResponse = new Response(init.body); + const headers = mergeBodyHeaders(init.headers, bodyResponse.headers); + return { + body: new Uint8Array(await bodyResponse.arrayBuffer()), + headers: toOutgoingHeaders(headers), + }; +}; + +const toWebResponse = (response: Http.IncomingMessage): Response => + new Response( + response.statusCode === 204 || response.statusCode === 304 ? null : Readable.toWeb(response), + { + status: response.statusCode ?? 200, + statusText: response.statusMessage ?? "", + headers: toResponseHeaders(response.headers), + }, + ); + +export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { + request: (socketPath, path, init) => + Effect.tryPromise({ + try: async () => { + const { body, headers } = await encodeRequest(init); + return await new Promise((resolve, reject) => { + const request = Http.request( + { + socketPath, + path, + method: init?.method ?? "GET", + headers, + signal: init?.signal ?? undefined, + }, + (response) => { + resolve(toWebResponse(response)); + }, + ); + + request.on("error", reject); + request.end(body); + }); + }, + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + }), +}); + +export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => + Layer.mergeAll( + NodeServices.layer, + Layer.unwrap( + releaseApiPort.pipe( + Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), + ), + ), + ); + +/** Internal child-process target. It is intentionally absent from package exports. */ +export const daemonEntryPoint = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts index f086bd5102..b68d4b5f4d 100644 --- a/packages/stack/src/prefetch.ts +++ b/packages/stack/src/prefetch.ts @@ -1,16 +1,20 @@ import { Effect } from "effect"; import type { ChecksumMismatchError } from "./errors.ts"; import type { DockerPullError } from "./errors.ts"; -import { type PreparedStackArtifacts, type StackPreparationInput } from "./StackPreparation.ts"; +import { + type PreparedStackArtifacts, + type ServiceResolution, + type StackPreparationInput, +} from "./StackPreparation.ts"; import { StackPreparation } from "./StackPreparation.ts"; -import type { ServiceResolution } from "./resolve.ts"; +import type { ServiceName } from "./ServiceName.ts"; export interface PrefetchOptions extends StackPreparationInput {} -export type PrefetchResult = Record; +export type PrefetchResult = Partial>; const toPrefetchResult = (artifacts: PreparedStackArtifacts): PrefetchResult => - artifacts.resolutions as PrefetchResult; + artifacts.resolutions; export const prefetch = ( options?: PrefetchOptions, diff --git a/packages/stack/src/resolve.ts b/packages/stack/src/resolve.ts deleted file mode 100644 index 813a7448f7..0000000000 --- a/packages/stack/src/resolve.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Effect } from "effect"; -import type { BinaryResolver } from "./BinaryResolver.ts"; -import type { ChecksumMismatchError } from "./errors.ts"; -import type { ServiceName } from "./versions.ts"; -import { dockerImageForService } from "./versions.ts"; - -export type ServiceResolution = - | { readonly type: "binary"; readonly path: string } - | { readonly type: "docker"; readonly image: string }; - -/** - * Resolve a service to either a native binary path or a Docker image. - * Tries BinaryResolver first; falls back to Docker on BinaryNotFoundError or DownloadError. - * ChecksumMismatchError is a real error and propagates. - */ -export const resolveService = ( - resolver: BinaryResolver["Service"], - service: ServiceName, - version: string, -): Effect.Effect => - resolver.resolve({ service, version }).pipe( - Effect.map((path): ServiceResolution => ({ type: "binary", path })), - Effect.catchTag("BinaryNotFoundError", () => - Effect.succeed({ - type: "docker", - image: dockerImageForService(service, version), - }), - ), - Effect.catchTag("DownloadError", () => - Effect.succeed({ - type: "docker", - image: dockerImageForService(service, version), - }), - ), - ); diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index e1ff634ed1..d34c86a519 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerPortMapArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -6,13 +7,11 @@ interface DockerAnalyticsOptions { readonly image: string; readonly apiPort: number; readonly hostPort: number; - readonly listenPort: number; - readonly nodeHost: string; + readonly platformOs: string; readonly dbHost: string; readonly dbPort: number; readonly apiKey: string; readonly backend: "postgres" | "bigquery"; - readonly networkArgs: ReadonlyArray; readonly dependencies: ReadonlyArray; } @@ -39,9 +38,10 @@ const analyticsHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ }); export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): ServiceDef => { + const runtimeNetwork = analyticsDockerRuntimeNetwork(opts.platformOs, opts.hostPort, opts.dbHost); const env: Record = { - PORT: String(opts.listenPort), - PHX_HTTP_PORT: String(opts.listenPort), + PORT: String(runtimeNetwork.listenPort), + PHX_HTTP_PORT: String(runtimeNetwork.listenPort), DB_DATABASE: "_supabase", DB_HOSTNAME: opts.dbHost, DB_PORT: String(opts.dbPort), @@ -53,7 +53,7 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic LOGFLARE_SUPABASE_MODE: "true", LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.apiKey, LOGFLARE_LOG_LEVEL: "warn", - LOGFLARE_NODE_HOST: opts.nodeHost, + LOGFLARE_NODE_HOST: runtimeNetwork.nodeHost, LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", RELEASE_COOKIE: "cookie", }; @@ -69,9 +69,11 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic return dockerRunService({ name: "analytics", - containerName: `supabase-analytics-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerPortMapArgs(opts.platformOs, [ + { host: opts.hostPort, container: ANALYTICS_CONTAINER_PORT }, + ]), entrypoint: "sh", cmd: [ "-c", @@ -84,7 +86,7 @@ EOF `, ], env, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: analyticsHealthCheck(opts.hostPort), }); }; diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index 355b8265c6..6e604695b6 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -1,6 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; interface AuthServiceOptions { readonly dbPort: number; @@ -13,10 +14,7 @@ interface AuthServiceOptions { readonly smtpPort?: number; readonly smtpAdminEmail?: string; readonly smtpSenderName?: string; - readonly dependencies: ReadonlyArray<{ - readonly service: string; - readonly condition: "healthy" | "completed"; - }>; + readonly dependencies: ReadonlyArray; } interface NativeAuthOptions extends AuthServiceOptions { @@ -26,7 +24,7 @@ interface NativeAuthOptions extends AuthServiceOptions { interface DockerAuthOptions extends AuthServiceOptions { readonly image: string; readonly dbHost: string; - readonly networkArgs: readonly string[]; + readonly platformOs: string; readonly apiPort: number; } @@ -82,17 +80,13 @@ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { const env = authEnv(opts, opts.dbHost); - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); - const containerName = `supabase-auth-${opts.apiPort}`; - - return { + return dockerRunService({ name: "auth", - command: "docker", - args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], + apiPort: opts.apiPort, + image: opts.image, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.authPort]), + env, dependencies: opts.dependencies, healthCheck: authHealthCheck(opts.authPort), - cleanup: dockerServiceCleanup(containerName), - supervision: { orphanCleanup: dockerServiceOrphanCleanup(containerName) }, - restart: "unless-stopped", - }; + }); }; diff --git a/packages/stack/src/services/docker-cleanup.ts b/packages/stack/src/services/docker-cleanup.ts index b3673a1b61..c96c7af6b9 100644 --- a/packages/stack/src/services/docker-cleanup.ts +++ b/packages/stack/src/services/docker-cleanup.ts @@ -14,7 +14,14 @@ export const dockerServiceCleanup = (containerName: string): Effect.Effect export const dockerServiceOrphanCleanup = ( containerName: string, -): ReadonlyArray => [{ _tag: "DockerRemove", containerName }]; +): ReadonlyArray => [ + { + _tag: "RunCommand", + executable: "docker", + args: ["rm", "-f", containerName], + timeoutMs: 5_000, + }, +]; export const removePathOnOrphanCleanup = ( path: string, diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index cda66721c5..7bdd1cd266 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -177,6 +177,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu const envVars = Object.entries({ ...config.env, + ...functionConfig.env, SUPABASE_URL: config.supabaseUrl, SUPABASE_ANON_KEY: config.publishableKey, SUPABASE_SERVICE_ROLE_KEY: config.secretKey, @@ -192,7 +193,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu workerTimeoutMs: 400000, noModuleCache: false, noNpm: false, - importMapPath: functionConfig.importMapPath, + importMapPath: functionConfig.importMapPath ?? undefined, envVars, forceCreate: false, customModuleRoot: "", diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 66f6d52b9d..bd3a35c66d 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -22,7 +23,7 @@ interface NativeEdgeRuntimeOptions extends EdgeRuntimeOptions { interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions { readonly image: string; readonly apiPort: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; } const bootstrapFileName = "index.ts"; @@ -85,9 +86,9 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se return dockerRunService({ name: "edge-runtime", - containerName: `supabase-edge-runtime-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [ `${bootstrapDir}:${bootstrapMountDir}:ro`, ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), @@ -98,7 +99,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, }, cmd: [...edgeRuntimeArgs(opts, bootstrapMountDir)], - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: edgeRuntimeHealthCheck(opts.port), }); }; diff --git a/packages/stack/src/services/health-budgets.ts b/packages/stack/src/services/health-budgets.ts index 7c623bfb6d..3cf965441f 100644 --- a/packages/stack/src/services/health-budgets.ts +++ b/packages/stack/src/services/health-budgets.ts @@ -1,5 +1,5 @@ import { defaults, type HealthCheckConfig } from "@supabase/process-compose"; -import type { ServiceName } from "../versions.ts"; +import type { ServiceName } from "../ServiceName.ts"; type HealthBudget = Required< Pick< diff --git a/packages/stack/src/services/imgproxy.ts b/packages/stack/src/services/imgproxy.ts index f38699f88e..a1526da08e 100644 --- a/packages/stack/src/services/imgproxy.ts +++ b/packages/stack/src/services/imgproxy.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -7,7 +8,7 @@ interface DockerImgproxyOptions { readonly port: number; readonly apiPort: number; readonly dataDir: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -21,9 +22,9 @@ const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => dockerRunService({ name: "imgproxy", - containerName: `supabase-imgproxy-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:${IMGPROXY_STORAGE_DIR}`], env: { IMGPROXY_BIND: `:${opts.port}`, @@ -36,6 +37,6 @@ export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceD IMGPROXY_PRESETS: "default=width:3000/height:8192", IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: imgproxyHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index e60f41a14e..edc8673e6a 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -1,5 +1,6 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerRunService, hostHttpHealthCheck } from "./service-utils.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; +import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; interface DockerMailpitOptions { @@ -8,7 +9,8 @@ interface DockerMailpitOptions { readonly webPort: number; readonly smtpPort: number; readonly pop3Port: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; + readonly dependencies: ReadonlyArray; } const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => @@ -19,9 +21,10 @@ const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef => dockerRunService({ name: "mailpit", - containerName: `supabase-mailpit-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.webPort, opts.smtpPort, opts.pop3Port]), + dependencies: opts.dependencies, env: { MP_UI_BIND_ADDR: `0.0.0.0:${opts.webPort}`, MP_SMTP_BIND_ADDR: `0.0.0.0:${opts.smtpPort}`, diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts index 67a258c6f2..26a9e26d7a 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -8,7 +9,7 @@ interface DockerPgmetaOptions { readonly port: number; readonly dbHost: string; readonly dbPort: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -26,9 +27,9 @@ const pgmetaHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef => dockerRunService({ name: "pgmeta", - containerName: `supabase-pgmeta-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), env: { PG_META_PORT: String(opts.port), PG_META_DB_HOST: opts.dbHost, @@ -37,6 +38,6 @@ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef = PG_META_DB_PORT: String(opts.dbPort), PG_META_DB_PASSWORD: "postgres", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: pgmetaHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts index 7d81e3edda..0091689aa5 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerPortMapArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -8,6 +9,7 @@ interface DockerPoolerOptions { readonly image: string; readonly apiPort: number; readonly hostAdminPort: number; + readonly hostPort: number; readonly dbHost: string; readonly dbPort: number; readonly poolMode: PoolMode; @@ -17,7 +19,7 @@ interface DockerPoolerOptions { readonly tenantId: string; readonly encryptionKey: string; readonly secretKeyBase: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -68,9 +70,18 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = (() => { return dockerRunService({ name: "pooler", - containerName: `supabase-pooler-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerPortMapArgs(opts.platformOs, [ + { host: opts.hostAdminPort, container: poolerContainerPorts.admin }, + { + host: opts.hostPort, + container: + opts.poolMode === "session" + ? poolerContainerPorts.session + : poolerContainerPorts.transaction, + }, + ]), env: { PORT: String(poolerContainerPorts.admin), PROXY_PORT_SESSION: String(poolerContainerPorts.session), @@ -91,7 +102,7 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = "-c", `/app/bin/migrate && /app/bin/supavisor eval '${tenantScript(opts)}' && /app/bin/server`, ], - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: poolerHealthCheck(opts.hostAdminPort), }); })(); diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 694951230d..63917352b1 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import type { ServiceDependency } from "./service-utils.ts"; interface PostgresInitOptions { readonly postgresDir: string; @@ -8,6 +9,7 @@ interface PostgresInitOptions { * Data API privileges on the `public` schema so newly-created entities require explicit GRANTs. */ readonly autoExposeNewTables: boolean; + readonly dependencies: ReadonlyArray; } /** @@ -136,7 +138,7 @@ END LD_LIBRARY_PATH: pgLibDir, PGPASSWORD: "postgres", }, - dependencies: [{ service: "postgres", condition: "healthy" }], + dependencies: opts.dependencies, supervision: {}, restart: "no", }; diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index 6968f989f8..296cc5d5bb 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -1,16 +1,20 @@ import { mkdirSync, writeFileSync } from "node:fs"; import type { ServiceDef } from "@supabase/process-compose"; -import { - dockerServiceCleanup, - dockerServiceOrphanCleanup, - removePathOnOrphanCleanup, -} from "./docker-cleanup.ts"; +import { dockerContainerName } from "../CleanupTargets.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; +import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import { + dockerExecHealthCheck, + dockerRunService, + type ServiceDependency, +} from "./service-utils.ts"; interface PostgresServiceOptions { readonly dataDir: string; readonly port: number; readonly cleanupDataDirOnExit?: boolean; + readonly dependencies: ReadonlyArray; } interface NativePostgresOptions extends PostgresServiceOptions { @@ -21,7 +25,7 @@ interface NativePostgresOptions extends PostgresServiceOptions { interface DockerPostgresOptions extends PostgresServiceOptions { readonly image: string; - readonly networkArgs: readonly string[]; + readonly platformOs: string; readonly jwtSecret: string; readonly jwtExpiry: number; readonly apiPort: number; @@ -101,14 +105,10 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ * queries with "unexpected EOF". We use `docker exec` to run pg_isready * inside the container, which verifies postgres is accepting commands. */ -const postgresDockerHealthCheck = (containerName: string, port: number) => ({ - probe: { - _tag: "Exec" as const, - command: "docker", - args: ["exec", containerName, "pg_isready", "-p", String(port), "-U", "postgres"], - }, - ...stackHealthBudgets.postgresDocker, -}); +const postgresDockerHealthCheck = (containerName: string, port: number) => + dockerExecHealthCheck(containerName, "pg_isready", ["-p", String(port), "-U", "postgres"], { + ...stackHealthBudgets.postgresDocker, + }); export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; @@ -146,6 +146,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => `hba_file=${customHbaPath}`, ], env: postgresEnv(opts), + dependencies: opts.dependencies, healthCheck: postgresHealthCheck(opts.binPath, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { @@ -163,6 +164,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => command: "bash", args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], env: postgresEnv(opts), + dependencies: opts.dependencies, healthCheck: postgresHealthCheck(opts.binPath, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { orphanCleanup: orphanCleanup(opts) }, @@ -172,33 +174,19 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { const env = postgresDockerEnv(opts); - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); - const containerName = `supabase-postgres-${opts.apiPort}`; - const dockerArgs = [ - "run", - "--rm", - "--name", - containerName, - ...opts.networkArgs, - "-v", - `${opts.dataDir}:/var/lib/postgresql/data`, - ...envArgs, - "--entrypoint", - "sh", - opts.image, - "-c", - dockerPostgresEntrypoint(opts.port), - ]; - return { + const containerName = dockerContainerName("postgres", opts.apiPort); + return dockerRunService({ name: "postgres", - command: "docker", - args: dockerArgs, + apiPort: opts.apiPort, + image: opts.image, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), + volumes: [`${opts.dataDir}:/var/lib/postgresql/data`], + env, + entrypoint: "sh", + cmd: ["-c", dockerPostgresEntrypoint(opts.port)], + dependencies: opts.dependencies, healthCheck: postgresDockerHealthCheck(containerName, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - cleanup: dockerServiceCleanup(containerName), - supervision: { - orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...orphanCleanup(opts)], - }, - restart: "unless-stopped", - }; + orphanCleanup: orphanCleanup(opts), + }); }; diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts index 0562277927..c8c7959de3 100644 --- a/packages/stack/src/services/postgrest.ts +++ b/packages/stack/src/services/postgrest.ts @@ -1,6 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; interface PostgrestServiceOptions { readonly dbPort: number; @@ -9,6 +10,7 @@ interface PostgrestServiceOptions { readonly extraSearchPath: ReadonlyArray; readonly maxRows: number; readonly jwtSecret: string; + readonly dependencies: ReadonlyArray; } interface NativePostgrestOptions extends PostgrestServiceOptions { @@ -18,7 +20,7 @@ interface NativePostgrestOptions extends PostgrestServiceOptions { interface DockerPostgrestOptions extends PostgrestServiceOptions { readonly image: string; readonly dbHost: string; - readonly networkArgs: readonly string[]; + readonly platformOs: string; readonly adminPort: number; readonly apiPort: number; } @@ -47,13 +49,11 @@ const postgrestHealthCheck = (port: number) => ({ ...stackHealthBudgets.postgrest, }); -const postgrestDependencies = [{ service: "postgres-init", condition: "completed" as const }]; - export const makePostgrestService = (opts: NativePostgrestOptions): ServiceDef => ({ name: "postgrest", command: `${opts.binPath}/postgrest`, env: postgrestEnv(opts), - dependencies: postgrestDependencies, + dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), supervision: {}, restart: "unless-stopped", @@ -64,17 +64,13 @@ export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): Servic ...postgrestEnv(opts, opts.dbHost), PGRST_ADMIN_SERVER_PORT: String(opts.adminPort), }; - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); - const containerName = `supabase-postgrest-${opts.apiPort}`; - - return { + return dockerRunService({ name: "postgrest", - command: "docker", - args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], - dependencies: postgrestDependencies, + apiPort: opts.apiPort, + image: opts.image, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port, opts.adminPort]), + env, + dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), - cleanup: dockerServiceCleanup(containerName), - supervision: { orphanCleanup: dockerServiceOrphanCleanup(containerName) }, - restart: "unless-stopped", - }; + }); }; diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index a8bd42eacd..68910a19e4 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -14,7 +15,7 @@ interface DockerRealtimeOptions { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -38,9 +39,9 @@ const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["health export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceDef => dockerRunService({ name: "realtime", - containerName: `supabase-realtime-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), env: { PORT: String(opts.port), DB_HOST: opts.dbHost, @@ -62,6 +63,6 @@ export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceD RUN_JANITOR: "true", MAX_HEADER_LENGTH: String(opts.maxHeaderLength), }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: realtimeHealthCheck(opts.port, opts.tenantId), }); diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts index eabb31cd5e..91a326eaea 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -1,4 +1,6 @@ -import type { ServiceDef } from "@supabase/process-compose"; +import type { ExternalCleanupAction, ServiceDef } from "@supabase/process-compose"; +import { dockerContainerName } from "../CleanupTargets.ts"; +import type { ServiceName } from "../ServiceName.ts"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; export interface ServiceDependency { @@ -7,8 +9,8 @@ export interface ServiceDependency { } interface DockerRunServiceOptions { - readonly name: string; - readonly containerName: string; + readonly name: ServiceName; + readonly apiPort: number; readonly image: string; readonly networkArgs?: ReadonlyArray; readonly env?: Record; @@ -16,11 +18,11 @@ interface DockerRunServiceOptions { readonly cmd?: ReadonlyArray; readonly entrypoint?: string; readonly volumes?: ReadonlyArray; - readonly dependsOn?: ReadonlyArray; + readonly dependencies: ReadonlyArray; readonly healthCheck?: ServiceDef["healthCheck"]; readonly restart?: ServiceDef["restart"]; readonly shutdown?: ServiceDef["shutdown"]; - readonly orphanCleanup?: ReadonlyArray; + readonly orphanCleanup?: ReadonlyArray; } const envArgs = (env: Record): ReadonlyArray => @@ -56,11 +58,12 @@ export const dockerExecHealthCheck = ( }); export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { + const containerName = dockerContainerName(opts.name, opts.apiPort); const dockerArgs = [ "run", "--rm", "--name", - opts.containerName, + containerName, ...(opts.networkArgs ?? []), ...(opts.volumes ?? []).flatMap((volume) => ["-v", volume]), ...(opts.entrypoint === undefined ? [] : ["--entrypoint", opts.entrypoint]), @@ -74,15 +77,12 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { name: opts.name, command: "docker", args: dockerArgs, - dependencies: opts.dependsOn, + dependencies: opts.dependencies, healthCheck: opts.healthCheck, shutdown: opts.shutdown, - cleanup: dockerServiceCleanup(opts.containerName), + cleanup: dockerServiceCleanup(containerName), supervision: { - orphanCleanup: [ - ...dockerServiceOrphanCleanup(opts.containerName), - ...(opts.orphanCleanup ?? []), - ], + orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...(opts.orphanCleanup ?? [])], }, restart: opts.restart ?? "unless-stopped", }; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index c777062c83..22f8730c4c 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -7,14 +7,20 @@ import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; +import { makePgmetaServiceDocker } from "./pgmeta.ts"; import { makePostgresInitService, REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL, } from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; -import { makePostgrestService } from "./postgrest.ts"; +import { makePostgrestService, makePostgrestServiceDocker } from "./postgrest.ts"; +import { makeRealtimeServiceDocker } from "./realtime.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; -import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET } from "./storage.ts"; +import { + LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + makeStorageServiceDocker, +} from "./storage.ts"; import { makeStudioServiceDocker } from "./studio.ts"; import { makeVectorServiceDocker } from "./vector.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; @@ -26,14 +32,13 @@ const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-a const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; const AUTH_BIN_PATH = `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`; const EDGE_RUNTIME_BIN_PATH = `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`; -const LINUX_HOST_GATEWAY_ARGS = ["--add-host", "host.docker.internal:host-gateway"]; - describe("makePostgresService", () => { it("creates a postgres ServiceDef with correct defaults", () => { const def = makePostgresService({ binPath: POSTGRES_BIN_PATH, dataDir: "/tmp/supabase/data", port: DB_PORT, + dependencies: [], }); expect(def.name).toBe("postgres"); @@ -61,7 +66,7 @@ describe("makePostgresService", () => { LD_LIBRARY_PATH: `${POSTGRES_BIN_PATH}/lib`, }, }); - expect(def.dependencies).toBeUndefined(); + expect(def.dependencies).toEqual([]); expect(def.restart).toBe("unless-stopped"); expect(def.supervision).toBeDefined(); }); @@ -101,7 +106,7 @@ describe("makeStudioServiceDocker", () => { analyticsBackend: "postgres", analyticsUrl: "http://host.docker.internal:54327", analyticsApiKey: "test-api-key", - networkArgs: ["-p", "54323:54323"], + platformOs: "darwin", dependencies: [{ service: "pgmeta", condition: "healthy" }], }); @@ -125,6 +130,7 @@ describe("makePostgresService (dockerAccessible)", () => { port: DB_PORT, dockerAccessible: true, cleanupDataDirOnExit: true, + dependencies: [], }); const customHbaPath = `${path.join(tempDir, "data")}_pg_hba_docker.conf`; @@ -166,10 +172,11 @@ describe("makePostgresServiceDocker", () => { image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", `${DB_PORT}:${DB_PORT}`], + platformOs: "linux", jwtSecret: "test-jwt-secret-with-at-least-32-characters", jwtExpiry: 3600, apiPort: API_PORT, + dependencies: [], }); expect(def.name).toBe("postgres"); @@ -197,10 +204,17 @@ describe("makePostgresServiceDocker", () => { "postgres", ], }); - expect(def.dependencies).toBeUndefined(); + expect(def.dependencies).toEqual([]); expect(def.restart).toBe("unless-stopped"); expect(def.supervision).toEqual({ - orphanCleanup: [{ _tag: "DockerRemove", containerName: `supabase-postgres-${API_PORT}` }], + orphanCleanup: [ + { + _tag: "RunCommand", + executable: "docker", + args: ["rm", "-f", `supabase-postgres-${API_PORT}`], + timeoutMs: 5_000, + }, + ], }); }); @@ -209,10 +223,11 @@ describe("makePostgresServiceDocker", () => { image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", `${DB_PORT}:${DB_PORT}`], + platformOs: "linux", jwtSecret: "test-jwt-secret-with-at-least-32-characters", jwtExpiry: 3600, apiPort: API_PORT, + dependencies: [], }); const script = def.args?.[def.args.length - 1] as string; @@ -236,6 +251,7 @@ describe("makePostgrestService", () => { extraSearchPath: ["public", "extensions"], maxRows: 1000, jwtSecret: JWT_SECRET, + dependencies: [{ service: "postgres-init", condition: "completed" }], }); expect(def.name).toBe("postgrest"); @@ -256,6 +272,38 @@ describe("makePostgrestService", () => { }); expect(def.supervision).toBeDefined(); }); + + it("creates a docker definition with caller-supplied topology and derived identity", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makePostgrestServiceDocker({ + image: dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest), + apiPort: API_PORT, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + port: 54323, + adminPort: 54324, + schemas: ["public", "storage"], + extraSearchPath: ["public", "extensions"], + maxRows: 1000, + jwtSecret: JWT_SECRET, + platformOs: "linux", + dependencies, + }); + + expect(def.command).toBe("docker"); + expect(def.args).toContain(`supabase-postgrest-${API_PORT}`); + expect(def.args).toContain("host.docker.internal:host-gateway"); + expect(def.args).toContain("54323:54323"); + expect(def.args).toContain("54324:54324"); + expect(def.args).toContain("PGRST_ADMIN_SERVER_PORT=54324"); + expect(def.dependencies).toEqual(dependencies); + expect(def.supervision?.orphanCleanup).toContainEqual({ + _tag: "RunCommand", + executable: "docker", + args: ["rm", "-f", `supabase-postgrest-${API_PORT}`], + timeoutMs: 5_000, + }); + }); }); describe("makeAuthServiceNative", () => { @@ -299,7 +347,7 @@ describe("makeAuthServiceDocker", () => { jwtExpiry: 3600, externalUrl: `http://127.0.0.1:${API_PORT}`, dbHost: "127.0.0.1", - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "9999:9999"], + platformOs: "linux", apiPort: API_PORT, dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -313,7 +361,14 @@ describe("makeAuthServiceDocker", () => { expect(def.args).toContain("9999:9999"); expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); expect(def.supervision).toEqual({ - orphanCleanup: [{ _tag: "DockerRemove", containerName: `supabase-auth-${API_PORT}` }], + orphanCleanup: [ + { + _tag: "RunCommand", + executable: "docker", + args: ["rm", "-f", `supabase-auth-${API_PORT}`], + timeoutMs: 5_000, + }, + ], }); }); }); @@ -331,7 +386,7 @@ describe("makeEdgeRuntimeServiceDocker", () => { inspectorPort: 54341, policy: "per_worker", env: { SUPABASE_INTERNAL_DEBUG: "true" }, - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54340:54340", "-p", "54341:54341"], + platformOs: "linux", dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -409,6 +464,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); expect(def.name).toBe("postgres-init"); @@ -427,6 +483,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).not.toContain("set -e"); @@ -437,6 +494,7 @@ describe("makePostgresInitService", () => { postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).toContain("authenticator"); @@ -448,6 +506,7 @@ describe("makePostgresInitService", () => { postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; @@ -463,6 +522,7 @@ describe("makePostgresInitService", () => { postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).not.toMatch(/sh .+migrate\.sh/); @@ -476,6 +536,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).not.toContain("alter default privileges"); @@ -487,6 +548,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: false, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).toContain(REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL); @@ -501,6 +563,90 @@ describe("makePostgresInitService", () => { }); describe("docker-backed auxiliary services", () => { + it("defines realtime command, topology, environment, and readiness locally", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makeRealtimeServiceDocker({ + image: dockerImageForService("realtime", DEFAULT_VERSIONS.realtime), + apiPort: API_PORT, + port: 54330, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + jwtSecret: JWT_SECRET, + jwtJwks: "test-jwks", + tenantId: "realtime-dev", + encryptionKey: "supabaserealtime", + secretKeyBase: "test-secret-key-base", + maxHeaderLength: 4096, + platformOs: "linux", + dependencies, + }); + + expect(def.args).toContain(`supabase-realtime-${API_PORT}`); + expect(def.args).toContain("54330:54330"); + expect(def.args).toContain("DB_HOST=host.docker.internal"); + expect(def.dependencies).toEqual(dependencies); + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Exec", command: "curl" }), + ); + }); + + it("defines storage mounts, cleanup, topology, and readiness locally", () => { + const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; + const def = makeStorageServiceDocker({ + image: dockerImageForService("storage", DEFAULT_VERSIONS.storage), + apiPort: API_PORT, + port: 54331, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + dataDir: "/tmp/supabase/storage", + anonKey: "anon-key", + serviceKey: "service-key", + jwtSecret: JWT_SECRET, + jwtJwks: "test-jwks", + fileSizeLimit: "50MiB", + enableImageTransformation: true, + imgproxyUrl: "http://host.docker.internal:54332", + s3ProtocolEnabled: true, + cleanupDataDirOnExit: true, + platformOs: "linux", + dependencies, + }); + + expect(def.args).toContain(`supabase-storage-${API_PORT}`); + expect(def.args).toContain("/tmp/supabase/storage:/var/lib/storage"); + expect(def.args).toContain("54331:54331"); + expect(def.dependencies).toEqual(dependencies); + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Http", port: 54331, path: "/status" }), + ); + expect(def.supervision?.orphanCleanup).toContainEqual({ + _tag: "RemovePath", + path: "/tmp/supabase/storage", + recursive: true, + }); + }); + + it("defines postgres metadata command, topology, environment, and readiness locally", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makePgmetaServiceDocker({ + image: dockerImageForService("pgmeta", DEFAULT_VERSIONS.pgmeta), + apiPort: API_PORT, + port: 54336, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + platformOs: "linux", + dependencies, + }); + + expect(def.args).toContain(`supabase-pgmeta-${API_PORT}`); + expect(def.args).toContain("54336:54336"); + expect(def.args).toContain("PG_META_DB_HOST=host.docker.internal"); + expect(def.dependencies).toEqual(dependencies); + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Http", port: 54336, path: "/health" }), + ); + }); + it("uses a host HTTP readiness probe for mailpit", () => { const def = makeMailpitServiceDocker({ image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), @@ -508,15 +654,8 @@ describe("docker-backed auxiliary services", () => { webPort: 54323, smtpPort: 54324, pop3Port: 54325, - networkArgs: [ - ...LINUX_HOST_GATEWAY_ARGS, - "-p", - "54323:54323", - "-p", - "54324:54324", - "-p", - "54325:54325", - ], + platformOs: "linux", + dependencies: [], }); expect(def.healthCheck?.probe).toEqual({ @@ -534,7 +673,7 @@ describe("docker-backed auxiliary services", () => { apiPort: API_PORT, port: 54326, dataDir: "/tmp/supabase/storage", - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54326:54326"], + platformOs: "linux", dependencies: [{ service: "storage", condition: "healthy" }], }); @@ -555,7 +694,7 @@ describe("docker-backed auxiliary services", () => { serviceHost: "127.0.0.1", analyticsPort: 54327, analyticsApiKey: "test-api-key", - networkArgs: [], + platformOs: "darwin", dependencies: [{ service: "analytics", condition: "healthy" }], }); @@ -577,13 +716,11 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), apiPort: API_PORT, hostPort: 54328, - listenPort: 4000, - nodeHost: "0.0.0.0", + platformOs: "darwin", dbHost: "127.0.0.1", dbPort: DB_PORT, apiKey: "test-api-key", backend: "postgres", - networkArgs: ["-p", "54328:4000"], dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -610,13 +747,11 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), apiPort: API_PORT, hostPort: 54328, - listenPort: 4000, - nodeHost: "0.0.0.0", + platformOs: "linux", dbHost: "host.docker.internal", dbPort: DB_PORT, apiKey: "test-api-key", backend: "postgres", - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54328:4000"], dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -632,6 +767,8 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("pooler", DEFAULT_VERSIONS.pooler), apiPort: API_PORT, hostAdminPort: 54329, + hostPort: 54330, + platformOs: "linux", dbHost: "127.0.0.1", dbPort: DB_PORT, poolMode: "transaction", @@ -641,12 +778,6 @@ describe("docker-backed auxiliary services", () => { tenantId: "pooler-dev", encryptionKey: "12345678901234567890123456789012", secretKeyBase: "1234567890123456789012345678901234567890123456789012345678901234", - networkArgs: [ - "-p", - `54329:${poolerContainerPorts.admin}`, - "-p", - `54330:${poolerContainerPorts.transaction}`, - ], dependencies: [{ service: "postgres", condition: "healthy" }], }); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index 87e21a6ab9..cf6f88e33a 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -18,7 +19,7 @@ interface DockerStorageOptions { readonly enableImageTransformation: boolean; readonly imgproxyUrl: string; readonly s3ProtocolEnabled: boolean; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; readonly cleanupDataDirOnExit?: boolean; } @@ -45,9 +46,9 @@ const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => dockerRunService({ name: "storage", - containerName: `supabase-storage-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:${STORAGE_DATA_DIR}`], env: { PORT: String(opts.port), @@ -75,7 +76,7 @@ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: storageHealthCheck(opts.port), orphanCleanup: orphanCleanup(opts), }); diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index 4b139abc96..f84c24c6a8 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -18,7 +19,7 @@ interface DockerStudioOptions { readonly analyticsBackend: "postgres" | "bigquery"; readonly analyticsUrl: string; readonly analyticsApiKey: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -36,9 +37,9 @@ const studioHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef => dockerRunService({ name: "studio", - containerName: `supabase-studio-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), env: { PORT: String(opts.port), CURRENT_CLI_VERSION: "local", @@ -64,6 +65,6 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", PGRST_DB_MAX_ROWS: "1000", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: studioHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts index 90aa557f04..c7b41c700a 100644 --- a/packages/stack/src/services/vector.ts +++ b/packages/stack/src/services/vector.ts @@ -1,4 +1,6 @@ import { existsSync } from "node:fs"; +import { dockerContainerName } from "../CleanupTargets.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerExecHealthCheck, dockerRunService, @@ -12,7 +14,7 @@ interface DockerVectorOptions { readonly serviceHost: string; readonly analyticsPort: number; readonly analyticsApiKey: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -40,7 +42,7 @@ sinks: `; export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { - const containerName = `supabase-vector-${opts.apiPort}`; + const containerName = dockerContainerName("vector", opts.apiPort); const dockerSocket = process.env.DOCKER_HOST?.startsWith("unix://") ? process.env.DOCKER_HOST.slice("unix://".length) : "/var/run/docker.sock"; @@ -48,9 +50,9 @@ export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { return dockerRunService({ name: "vector", - containerName, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, []), volumes, env: { DOCKER_HOST: "unix:///var/run/docker.sock", @@ -62,7 +64,7 @@ export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { ${VECTOR_CONFIG(opts.serviceHost, opts.analyticsPort, opts.analyticsApiKey)}EOF `, ], - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: dockerExecHealthCheck( containerName, "sh", diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts new file mode 100644 index 0000000000..206459eeeb --- /dev/null +++ b/packages/stack/src/testing.ts @@ -0,0 +1,3 @@ +/** Test-only service tags for building deterministic consumer layers. */ +export { DaemonServer } from "./DaemonServer.ts"; +export { UnixHttpClient } from "./UnixHttpClient.ts"; diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index 6a9c4a1a9c..9e6295fac0 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -1,71 +1,16 @@ import { + DEFAULT_VERSIONS, + SERVICE_NAMES, dockerImageCandidatesForArtifact, dockerImageForArtifact, imageTagPrefixForService, -} from "./ServiceArtifacts.ts"; +} from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; -export type ServiceName = - | "postgres" - | "postgrest" - | "auth" - | "edge-runtime" - | "realtime" - | "storage" - | "imgproxy" - | "mailpit" - | "pgmeta" - | "studio" - | "analytics" - | "vector" - | "pooler"; +export { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; +export type { ServiceName } from "./ServiceName.ts"; -export const SERVICE_NAMES = [ - "postgres", - "postgrest", - "auth", - "edge-runtime", - "realtime", - "storage", - "imgproxy", - "mailpit", - "pgmeta", - "studio", - "analytics", - "vector", - "pooler", -] as const satisfies ReadonlyArray; - -export interface VersionManifest { - readonly postgres: string; - readonly postgrest: string; - readonly auth: string; - readonly "edge-runtime": string; - readonly realtime: string; - readonly storage: string; - readonly imgproxy: string; - readonly mailpit: string; - readonly pgmeta: string; - readonly studio: string; - readonly analytics: string; - readonly vector: string; - readonly pooler: string; -} - -export const DEFAULT_VERSIONS: VersionManifest = { - postgres: "17.6.1.158", - postgrest: "14.16", - auth: "2.195.0", - "edge-runtime": "1.74.3", - realtime: "2.123.1", - storage: "1.68.1", - imgproxy: "v3.8.0", - mailpit: "v1.30.2", - pgmeta: "0.96.6", - studio: "2026.08.03-sha-022b374", - analytics: "1.49.2", - vector: "0.53.0-alpine", - pooler: "2.9.7", -} as const; +export type VersionManifest = Readonly>; export const IMAGE_TAG_PREFIX: Partial> = Object.fromEntries( SERVICE_NAMES.flatMap((service) => { diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts index 4b31249dbd..bd5e584c3f 100644 --- a/packages/stack/src/versions.unit.test.ts +++ b/packages/stack/src/versions.unit.test.ts @@ -13,7 +13,7 @@ import { SERVICE_NAMES, type VersionManifest, } from "./versions.ts"; -import { SERVICE_ARTIFACTS } from "./ServiceArtifacts.ts"; +import { SERVICE_CATALOG } from "./ServiceCatalog.ts"; const sampleDockerfile = ` FROM supabase/postgres:17.0.0.1 AS pg @@ -35,44 +35,29 @@ FROM supabase/migra:3.0.1663481299 AS migra describe("syncDefaultVersionsSource", () => { it("rewrites the DEFAULT_VERSIONS block from Dockerfile versions", () => { - const source = `before -export const DEFAULT_VERSIONS: VersionManifest = { - postgres: "old", - postgrest: "old", - auth: "old", - "edge-runtime": "old", - realtime: "old", - storage: "old", - imgproxy: "old", - mailpit: "old", - pgmeta: "old", - studio: "old", - analytics: "old", - vector: "old", - pooler: "old", -} as const; -after`; - - expect(syncDefaultVersionsSource(source, readVersionManifestFromDockerfile(sampleDockerfile))) - .toMatchInlineSnapshot(` - "before - export const DEFAULT_VERSIONS: VersionManifest = { - postgres: "17.0.0.1", - postgrest: "14.0", - auth: "2.100.0", - "edge-runtime": "1.70.0", - realtime: "2.100.0", - storage: "1.50.0", - imgproxy: "v3.8.0", - mailpit: "v1.2.3", - pgmeta: "0.90.0", - studio: "2026.01.01-sha-abcdef0", - analytics: "1.40.0", - vector: "0.50.0-alpine", - pooler: "2.1.0", - } as const; - after" - `); + const source = SERVICE_NAMES.map( + (service) => ` ${JSON.stringify(service)}: { + name: ${JSON.stringify(service)}, + configKey: "example", + defaultVersion: "old", + },`, + ).join("\n"); + + const updated = syncDefaultVersionsSource( + source, + readVersionManifestFromDockerfile(sampleDockerfile), + ); + + expect(updated).toContain( + 'name: "postgres",\n configKey: "example",\n defaultVersion: "17.0.0.1"', + ); + expect(updated).toContain( + 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "1.70.0"', + ); + expect(updated).toContain( + 'name: "mailpit",\n configKey: "example",\n defaultVersion: "v1.2.3"', + ); + expect(updated).not.toContain('defaultVersion: "old"'); }); it("fails when a required Dockerfile image alias is missing", () => { @@ -92,7 +77,7 @@ after`; describe("dockerImageForService", () => { it("defines artifact capabilities for every stack service", () => { - expect(Object.keys(SERVICE_ARTIFACTS).sort()).toEqual([...SERVICE_NAMES].sort()); + expect(Object.keys(SERVICE_CATALOG).sort()).toEqual([...SERVICE_NAMES].sort()); }); it("returns correct image for postgres", () => { @@ -134,17 +119,17 @@ describe("dockerImageForService", () => { }); it("keeps non-managed services Docker-only", () => { - expect(SERVICE_ARTIFACTS.imgproxy).toMatchObject({ + expect(SERVICE_CATALOG.imgproxy).toMatchObject({ runtimeSupport: "docker-only", - docker: { ownership: "upstream", repository: "darthsim/imgproxy" }, + artifact: { docker: { ownership: "upstream", repository: "darthsim/imgproxy" } }, }); - expect(SERVICE_ARTIFACTS.mailpit).toMatchObject({ + expect(SERVICE_CATALOG.mailpit).toMatchObject({ runtimeSupport: "docker-only", - docker: { ownership: "upstream", repository: "axllent/mailpit" }, + artifact: { docker: { ownership: "upstream", repository: "axllent/mailpit" } }, }); - expect(SERVICE_ARTIFACTS.vector).toMatchObject({ + expect(SERVICE_CATALOG.vector).toMatchObject({ runtimeSupport: "docker-only", - docker: { ownership: "upstream", repository: "timberio/vector" }, + artifact: { docker: { ownership: "upstream", repository: "timberio/vector" } }, }); }); }); diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index f957492e72..8f81ad820e 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -101,11 +101,8 @@ dockerDescribe("createStack e2e (docker mode)", () => { async () => { const functionsRes = await fetch(`${stack.url}/functions/v1/test`); await stack.serviceReady("edge-runtime"); - - const [runningImages, states] = await Promise.all([ - Promise.resolve(execSync("docker ps --format '{{.Image}}'").toString()), - stack.getStatus(), - ]); + const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); + const states = await stack.getStatus(); expect(runningImages).toContain("supabase/edge-runtime"); expect(states).toEqual( diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 9b734fa450..6511c750ad 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack, type StackHandle } from "../src/node.ts"; +import { createStack, type ResolvedFunctionsBundle, type StackHandle } from "../src/node.ts"; import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; const STACK_E2E_TEST_TIMEOUT_MS = 5_000; @@ -21,7 +21,7 @@ describe("createStack e2e", () => { stack = await createStack({ projectDir, - functions: { noVerifyJwt: true }, + functions: functionsBundle(projectDir, ["hello"]), jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, }); @@ -89,7 +89,7 @@ describe("createStack e2e", () => { test("reloadFunctions picks up newly added Edge Functions", { timeout: 30_000 }, async () => { writeFunction(projectDir, "later", "later"); - await stack.reloadFunctions({ noVerifyJwt: true }); + await stack.reloadFunctions({ functions: functionsBundle(projectDir, ["hello", "later"]) }); const res = await fetchFunctionWhenReady(`${stack.url}/functions/v1/later`); @@ -168,3 +168,20 @@ function writeFunction(projectDir: string, slug: string, body: string) { `Deno.serve(() => new Response(${JSON.stringify(body)}));\n`, ); } + +function functionsBundle( + projectDir: string, + names: ReadonlyArray, +): ResolvedFunctionsBundle { + return { + env: {}, + functions: names.map((name) => ({ + name, + verifyJWT: false, + entrypointPath: join(projectDir, "supabase", "functions", name, "index.ts"), + importMapPath: null, + staticFiles: [], + env: {}, + })), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 773f12d5f3..4624e456dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,9 +517,6 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1) - '@supabase/config': - specifier: workspace:* - version: link:../config '@supabase/process-compose': specifier: workspace:* version: link:../process-compose