diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index a4c4509b3e..9f631fa37d 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -237,6 +237,10 @@ export const localStackLayer = ( config.functions === false ? undefined : config.functions, ); const edgeRuntimeConfigRef = yield* Ref.make(config.edgeRuntime); + // A whole-stack stop changes the orchestrator desired state for every running service to + // `stopped`. Keep that lifecycle intent separate from an explicit service stop so a later + // lazy activation can restore only services that were running before the whole-stack stop. + const wholeStackStoppedServicesRef = yield* Ref.make>(new Set()); const disposedSignal = yield* Deferred.make(); const lifecycleLock = Semaphore.makeUnsafe(1); const projectionLock = Semaphore.makeUnsafe(1); @@ -626,6 +630,25 @@ export const localStackLayer = ( const withLifecycleLock = lifecycleLock.withPermit; const syncRuntimeProjectedStates = (runtime: RuntimeState) => syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); + const clearWholeStackStopAllowance = (services: ReadonlyArray) => + Ref.update(wholeStackStoppedServicesRef, (current) => { + const next = new Set(current); + for (const service of services) next.delete(service); + return next; + }); + const wholeStackStopAllowance = Ref.get(wholeStackStoppedServicesRef); + const rememberWholeStackStoppedServices = (runtime: RuntimeState) => + Effect.gen(function* () { + const running = (yield* runtime.orchestrator.getAllStates).flatMap((state) => { + if (state.desired !== "running") return []; + const service = SERVICE_NAMES.find((candidate) => candidate === state.name); + return service !== undefined && enabledServices.includes(service) ? [service] : []; + }); + yield* Ref.update( + wholeStackStoppedServicesRef, + (current) => new Set([...current, ...running]), + ); + }); const serviceStartOptions = { // Reservation may yield while disposal flips the lifecycle state. beforeStart: (name: string) => @@ -736,6 +759,11 @@ export const localStackLayer = ( return yield* new StackNotRunningError({ phase }); } }); + const clearWholeStackStopAllowanceAfterSuccess = (services: ReadonlyArray) => + Effect.gen(function* () { + yield* requireRunningPhase; + yield* clearWholeStackStopAllowance(services); + }).pipe(lifecycleLock.withPermit); const requireMutable = (operation: string) => Effect.suspend(() => disposed || disposing @@ -849,6 +877,9 @@ export const localStackLayer = ( // Close the race with a concurrent stack stop before taking // the lock-free healthy-request fast path. yield* requireRunningPhase; + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); return; } if (existing !== undefined) { @@ -859,6 +890,9 @@ export const localStackLayer = ( activationReadinessPolicy(service, config.readiness, config.readinessSource), ), ); + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); return; } yield* prepareServices([service]); @@ -866,7 +900,8 @@ export const localStackLayer = ( yield* requireRunningPhase; const concurrentlyStarted = yield* inspectStartedTargets(service); if (concurrentlyStarted !== undefined) return concurrentlyStarted; - return yield* beginStartTargets(service, new Set()); + const allowedWholeStackStops = yield* wholeStackStopAllowance; + return yield* beginStartTargets(service, allowedWholeStackStops); }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy( @@ -875,6 +910,9 @@ export const localStackLayer = ( activationReadinessPolicy(service, config.readiness, config.readinessSource), ), ); + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); }).pipe(cleanupOnReadinessFailure); const stack = { @@ -933,6 +971,7 @@ export const localStackLayer = ( (effect) => withReadinessPolicy(effect, "stack"), ); yield* syncRuntimeProjectedStates(runtime); + yield* clearWholeStackStopAllowance(["postgres", ...eager]); } else { yield* prepareServices(enabledServices); yield* requireMutable("start"); @@ -942,6 +981,7 @@ export const localStackLayer = ( withReadinessPolicy(effect, "stack"), ); yield* syncRuntimeProjectedStates(runtime); + yield* clearWholeStackStopAllowance(enabledServices); } yield* requireMutable("start"); yield* Ref.set(phaseRef, "running"); @@ -956,10 +996,16 @@ export const localStackLayer = ( if (disposed) { return; } + const phase = yield* Ref.get(phaseRef); + if (phase === "stopped") { + return; + } if (runtimeState === undefined) { + yield* Ref.set(wholeStackStoppedServicesRef, new Set()); yield* Ref.set(phaseRef, "stopped"); return; } + yield* rememberWholeStackStoppedServices(runtimeState); yield* Ref.set(phaseRef, "stopping"); yield* runtimeState.orchestrator.stop; yield* Ref.set(phaseRef, "stopped"); @@ -974,12 +1020,19 @@ export const localStackLayer = ( const started = yield* Effect.gen(function* () { yield* requireMutable(`start service ${name}`); yield* requireRunningPhase; + const allowedWholeStackStops = yield* wholeStackStopAllowance; return yield* beginStartTargets( service, - new Set(lifecycleTargetsForService(enabledServices, service)), + new Set([ + ...allowedWholeStackStops, + ...lifecycleTargetsForService(enabledServices, service), + ]), ); }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); }).pipe(cleanupOnReadinessFailure), stopService: (name) => Effect.gen(function* () { @@ -993,6 +1046,9 @@ export const localStackLayer = ( ).toReversed()) { yield* runtime.orchestrator.stopService(target); } + yield* clearWholeStackStopAllowance( + lifecycleTargetsForService(enabledServices, service), + ); // Settle the public projection before returning so callers observe // the stop immediately, matching the start/restart/waitReady paths. yield* syncRuntimeProjectedStates(runtime); @@ -1011,6 +1067,7 @@ export const localStackLayer = ( return { runtime, targets: [service] }; }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + yield* clearWholeStackStopAllowanceAfterSuccess([service]); }).pipe(cleanupOnReadinessFailure), reloadFunctions: (opts) => Effect.gen(function* () { diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index e638d17336..3164d9c447 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -886,7 +886,7 @@ describe("Stack", () => { }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); - it.live("restarts activated companions after stopping the stack", () => { + it.live("restarts activated analytics companions across repeated stack cycles", () => { const graph = Effect.runSync( buildGraph([ { @@ -962,10 +962,10 @@ describe("Stack", () => { ...defaultConfig.servicePolicies, auth: "off", postgrest: "lazy", - pgmeta: "eager", - studio: "eager", - analytics: "eager", - vector: "eager", + pgmeta: "off", + studio: "off", + analytics: "lazy", + vector: "lazy", }, auth: false, } satisfies ResolvedStackConfig; @@ -980,16 +980,129 @@ describe("Stack", () => { return Effect.gen(function* () { const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start; + yield* activator.activate("analytics"); + expect((yield* stack.getState("analytics")).status).toBe("Healthy"); + expect((yield* stack.getState("vector")).status).toBe("Healthy"); + yield* stack.stop; yield* stack.start; + yield* stack.restartService("analytics"); + yield* activator.activate("analytics"); + expect((yield* stack.getState("analytics")).status).toBe("Healthy"); + expect((yield* stack.getState("vector")).status).toBe("Healthy"); yield* stack.stop; yield* stack.start; + yield* stack.stop; + yield* stack.start; + yield* activator.activate("analytics"); - expect((yield* stack.getState("studio")).status).toBe("Healthy"); expect((yield* stack.getState("analytics")).status).toBe("Healthy"); expect((yield* stack.getState("vector")).status).toBe("Healthy"); }).pipe(Effect.provide(layer), Effect.timeout("10 seconds")); }); + it.live("retains lazy companion allowances when an interrupted stack stop is retried", () => + Effect.gen(function* () { + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "analytics", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + cleanup: Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCleanup)), + ), + }, + { + name: "vector", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + ]), + ); + const config = { + ...defaultConfig, + runtime: { mode: "docker", containerRuntime: "docker" }, + postgrest: false, + auth: false, + analytics: { + port: defaultPorts.analyticsPort, + version: DEFAULT_VERSIONS.analytics, + backend: "postgres", + apiKey: "test-api-key", + }, + vector: { version: DEFAULT_VERSIONS.vector }, + servicePolicies: { + ...defaultConfig.servicePolicies, + auth: "off", + postgrest: "off", + analytics: "lazy", + vector: "lazy", + }, + } satisfies ResolvedStackConfig; + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["analytics", { visibility: "public" as const }], + ["vector", { visibility: "public" as const }], + ]), + }), + }); + const { resolver, spawner } = setupLayer(config, noopPortLease(config.ports)); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start; + yield* activator.activate("analytics"); + + const stopping = yield* (yield* stack.stateChanges("analytics")).pipe( + Stream.filter((state) => state.status === "Stopping"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const stoppingStack = yield* stack.stop.pipe(Effect.forkChild({ startImmediately: true })); + expect(Option.isSome(yield* Fiber.join(stopping))).toBe(true); + yield* Deferred.await(cleanupStarted); + + const interrupting = yield* Fiber.interrupt(stoppingStack).pipe( + Effect.forkChild({ startImmediately: true }), + ); + // Immediate evaluation delivers the interruption before returning while + // the interrupt effect waits for the gated cleanup to finish. + yield* Deferred.succeed(releaseCleanup, undefined); + yield* Fiber.join(interrupting); + + yield* stack.stop; + yield* stack.start; + yield* activator.activate("analytics"); + expect((yield* stack.getState("analytics")).status).toBe("Healthy"); + expect((yield* stack.getState("vector")).status).toBe("Healthy"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("10 seconds")), + ); + it.live("rejects a cached start when disposal begins during startup", () => Effect.gen(function* () { const startEntered = yield* Deferred.make(); diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 821af22053..7a31121494 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -43,14 +43,19 @@ export async function prefetch(options?: PrefetchOptions): Promise { throw toStackError(error); }); - const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( + const resolverLayer = BinaryResolver.make(options?.cacheRoot ?? defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const effectOptions = { + versions: options?.versions, + services: options?.services, + enabledServices: options?.enabledServices, + }; const resolvedOptions: PrefetchEffectOptions = runtime.mode === "native" - ? { ...options, mode: "native" } - : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; + ? { ...effectOptions, mode: "native" } + : { ...effectOptions, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index a482ef0b0a..5d7516db5e 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -29,6 +29,7 @@ export type { ServiceName, VersionManifest } from "./versions.ts"; export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { StackHandle } from "./stackHandle.ts"; +export { StackError } from "./errors.ts"; export type { FunctionsReloadConfig, FunctionsRuntimeConfig, diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index 3a5aa6ba6e..cc0dd30d47 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -50,14 +50,19 @@ export async function prefetch(options?: PrefetchOptions): Promise { throw toStackError(error); }); - const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( + const resolverLayer = BinaryResolver.make(options?.cacheRoot ?? defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const effectOptions = { + versions: options?.versions, + services: options?.services, + enabledServices: options?.enabledServices, + }; const resolvedOptions: PrefetchEffectOptions = runtime.mode === "native" - ? { ...options, mode: "native" } - : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; + ? { ...effectOptions, mode: "native" } + : { ...effectOptions, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts index c4a26af9fc..d843fac44b 100644 --- a/packages/stack/src/prefetch.ts +++ b/packages/stack/src/prefetch.ts @@ -10,13 +10,15 @@ import { StackPreparation } from "./StackPreparation.ts"; import type { ServiceName } from "./ServiceName.ts"; export interface PrefetchOptions { + /** Root directory used for native binary cache entries. */ + readonly cacheRoot?: string; readonly versions?: StackPreparationInput["versions"]; readonly services?: StackPreparationInput["services"]; readonly enabledServices?: StackPreparationInput["enabledServices"]; readonly mode?: "native" | "docker"; } -export type PrefetchEffectOptions = Omit & +export type PrefetchEffectOptions = Omit & ( | { readonly mode?: "native"; readonly containerRuntime?: never } | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } diff --git a/packages/stack/tests/createStack-native.e2e.test.ts b/packages/stack/tests/createStack-native.e2e.test.ts index 5ac31fa5c6..56dc3094de 100644 --- a/packages/stack/tests/createStack-native.e2e.test.ts +++ b/packages/stack/tests/createStack-native.e2e.test.ts @@ -1,59 +1,419 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Native e2e tests await subprocess-backed stack operations and use filesystem/path fixtures. +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/process-env -- Native e2e tests await subprocess-backed stack operations and use filesystem/path fixtures. import { createClient } from "@supabase/supabase-js"; -import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { Predicate } from "effect"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack, type StackHandle } from "../src/node.ts"; -import { defaultCacheRoot } from "../src/paths.ts"; +import { createStack, prefetch, StackError, type StackHandle } from "@supabase/stack"; import { setupTestTable } from "./helpers/e2e.ts"; +const activateWithoutDownload = async ( + stack: StackHandle, + service: string, + activate: () => Promise, +): Promise => { + const iterator = stack.statusChanges()[Symbol.asyncIterator](); + const first = iterator.next(); + const observed: string[] = []; + try { + await activate(); + let event = await first; + while (!event.done) { + if (event.value.name === service) { + observed.push(event.value.status); + if (event.value.status === "Healthy") break; + } + event = await iterator.next(); + } + } finally { + await iterator.return?.(); + } + expect(observed).toContain("Healthy"); + expect(observed).not.toContain("Downloading"); +}; + +const drain = async (response: Response): Promise => { + await response.arrayBuffer(); +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && Reflect.get(error, "code") !== "ESRCH"; + } +}; + +const bindAndClose = async (port: number): Promise => { + const server = createServer(); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", onError); + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + }); +}; + +const stagingEntries = (root: string): ReadonlyArray => { + if (!existsSync(root)) return []; + const found: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.name.includes(".partial-") || entry.name.includes(".publication-lock")) { + found.push(path); + } + if (entry.isDirectory()) found.push(...stagingEntries(path)); + } + return found; +}; + +const markerValue = (path: string, key: string): unknown => { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; +}; + describe("native PostgREST tracer bullet", () => { const jwtSecret = "native-e2e-jwt-secret-with-at-least-32-characters"; let stack: StackHandle; let dataDir: string; - let cacheParent: string; + let cacheRoot: string; + let authCachePath: string; + let postgresCachePath: string; + let postgrestCachePath: string; + let stackRoot: string; + let runtimeRoot: string; + let sentinelBin: string; + let sentinelMarker: string; + let originalPath: string | undefined; + let stackDisposed = false; beforeAll(async () => { dataDir = mkdtempSync(join(tmpdir(), "supabase-native-postgrest-e2e-")); - cacheParent = mkdtempSync(join(tmpdir(), "supabase-native-cache-parent-")); - const cacheRoot = join(cacheParent, "cache root with spaces"); - symlinkSync(defaultCacheRoot(), cacheRoot, "dir"); + cacheRoot = mkdtempSync(join(tmpdir(), "supabase-native-cache-")); + stackRoot = mkdtempSync(join(tmpdir(), "supabase-native-stack-root-")); + runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-native-runtime-root-")); + sentinelBin = mkdtempSync(join(tmpdir(), "supabase-native-runtime-sentinel-")); + sentinelMarker = join(sentinelBin, "invoked"); + originalPath = process.env.PATH; + for (const executable of ["docker", "podman"]) { + const path = join(sentinelBin, executable); + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$0" >> "${sentinelMarker}"\n`, "utf8"); + chmodSync(path, 0o755); + } + process.env.PATH = [sentinelBin, originalPath].filter((value) => value !== undefined).join(":"); stack = await createStack({ mode: "native", cacheRoot, + stackRoot, + runtimeRoot, functions: false, edgeRuntime: false, - auth: false, jwtSecret, postgres: { dataDir }, }); await stack.start(); + expect(existsSync(join(cacheRoot, "bin", "slim-services", "auth"))).toBe(false); + expect(existsSync(join(cacheRoot, "bin", "slim-services", "postgrest"))).toBe(false); + expect(existsSync(sentinelMarker)).toBe(false); + + const warmed = await prefetch({ + mode: "native", + cacheRoot, + services: ["auth", "postgrest"], + }); + expect(warmed.postgres?.type).toBe("binary"); + expect(warmed.auth?.type).toBe("binary"); + expect(warmed.postgrest?.type).toBe("binary"); + expect(warmed.postgres?.type === "binary" && warmed.postgres.path.startsWith(cacheRoot)).toBe( + true, + ); + expect(warmed.auth?.type === "binary" && warmed.auth.path.startsWith(cacheRoot)).toBe(true); + expect(warmed.postgrest?.type === "binary" && warmed.postgrest.path.startsWith(cacheRoot)).toBe( + true, + ); + if (warmed.auth?.type !== "binary") throw new Error("native Auth was not prefetched"); + if (warmed.postgres?.type !== "binary") throw new Error("native PostgreSQL was not prefetched"); + if (warmed.postgrest?.type !== "binary") throw new Error("native PostgREST was not prefetched"); + authCachePath = warmed.auth.path; + postgresCachePath = warmed.postgres.path; + postgrestCachePath = warmed.postgrest.path; + expect(existsSync(sentinelMarker)).toBe(false); await setupTestTable(parseInt(new URL(stack.dbUrl).port)); - }, 45_000); + }, 180_000); afterAll(async () => { - await stack?.dispose(); - rmSync(dataDir, { recursive: true, force: true }); - rmSync(cacheParent, { recursive: true, force: true }); + try { + if (!stackDisposed) await stack?.dispose(); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (dataDir !== undefined) rmSync(dataDir, { recursive: true, force: true }); + if (cacheRoot !== undefined) rmSync(cacheRoot, { recursive: true, force: true }); + if (stackRoot !== undefined) rmSync(stackRoot, { recursive: true, force: true }); + if (runtimeRoot !== undefined) rmSync(runtimeRoot, { recursive: true, force: true }); + if (sentinelBin !== undefined) rmSync(sentinelBin, { recursive: true, force: true }); + } + }, 120_000); + + test("keeps lazy Auth and PostgREST dormant until their first requests", async () => { + const statuses = await stack.getStatus(); + expect(statuses.find((state) => state.name === "postgres")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "auth")?.status).toBe("Dormant"); + expect(statuses.find((state) => state.name === "postgrest")?.status).toBe("Dormant"); }, 30_000); + test("retries Auth signup and password sessions after a corrected JIT preparation failure", async () => { + const markerPath = join(authCachePath, ".complete"); + const marker = readFileSync(markerPath); + const asset = markerValue(markerPath, "asset"); + if (typeof asset !== "string") throw new Error("native Auth marker has no asset name"); + const authParent = dirname(authCachePath); + const movedParent = `${authParent}.fault`; + const stalePartial = join(authParent, `.${asset}.partial-stale`); + const staleLock = join(authParent, `.${asset}.publication-lock`); + mkdirSync(stalePartial, { recursive: true }); + mkdirSync(staleLock, { recursive: true }); + utimesSync(stalePartial, new Date(0), new Date(0)); + utimesSync(staleLock, new Date(0), new Date(0)); + rmSync(movedParent, { recursive: true, force: true }); + renameSync(authParent, movedParent); + writeFileSync(authParent, "native-auth-preparation-fault", "utf8"); + try { + const failedRequest = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(failedRequest.status).toBe(503); + await drain(failedRequest); + + let failure: unknown; + try { + await stack.startService("auth"); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(StackError); + if (!(failure instanceof StackError)) + throw new Error("Auth preparation did not fail publicly"); + expect(failure.code).toBe("BUILD_ERROR"); + expect(Predicate.isTagged(failure.cause, "StackBuildError")).toBe(true); + expect(await stack.getServiceStatus("postgres")).toMatchObject({ status: "Healthy" }); + expect(await stack.getServiceStatus("auth")).toMatchObject({ status: "Dormant" }); + } finally { + rmSync(authParent, { force: true }); + renameSync(movedParent, authParent); + } + + const authEmail = `native-${Date.now()}@example.com`; + const authPassword = "native-password-123"; + const client = createClient(stack.url, stack.publishableKey); + await activateWithoutDownload(stack, "auth", async () => { + const signup = await client.auth.signUp({ email: authEmail, password: authPassword }); + expect(signup.error).toBeNull(); + expect(signup.data.user?.email).toBe(authEmail); + expect(signup.data.session).not.toBeNull(); + }); + await client.auth.signOut(); + const signIn = await client.auth.signInWithPassword({ + email: authEmail, + password: authPassword, + }); + expect(signIn.error).toBeNull(); + expect(signIn.data.user?.email).toBe(authEmail); + expect(signIn.data.session).not.toBeNull(); + expect(readFileSync(markerPath)).toEqual(marker); + expect(existsSync(stalePartial)).toBe(false); + expect(existsSync(staleLock)).toBe(false); + expect(existsSync(sentinelMarker)).toBe(false); + }, 30_000); + + test("starts eager native Auth and PostgREST before reporting readiness", async () => { + const eagerDataDir = mkdtempSync(join(tmpdir(), "supabase-native-eager-data-")); + const eager = await createStack({ + mode: "native", + cacheRoot, + functions: false, + edgeRuntime: false, + servicePolicies: { auth: "eager", postgrest: "eager" }, + jwtSecret, + postgres: { dataDir: eagerDataDir }, + }); + try { + await eager.start(); + const statuses = await eager.getStatus(); + expect(statuses.find((state) => state.name === "postgres")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "auth")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "postgrest")?.status).toBe("Healthy"); + } finally { + await eager.dispose(); + rmSync(eagerDataDir, { recursive: true, force: true }); + } + expect(existsSync(sentinelMarker)).toBe(false); + }, 60_000); + test("serves a CRUD request through the native PostgREST resource", async () => { const client = createClient(stack.url, stack.publishableKey); - const inserted = await client + let inserted: { id: number; title: string; completed: boolean } | undefined; + await activateWithoutDownload(stack, "postgrest", async () => { + const result = await client + .from("todos") + .insert({ title: "native tracer bullet" }) + .select() + .single(); + expect(result.error).toBeNull(); + expect(result.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); + if (result.data === null) throw new Error("PostgREST insert returned no row"); + inserted = result.data; + }); + if (inserted === undefined) throw new Error("PostgREST insert did not produce a row"); + + const read = await client.from("todos").select().eq("id", inserted.id).single(); + expect(read.error).toBeNull(); + expect(read.data).toEqual(inserted); + + const updated = await client .from("todos") - .insert({ title: "native tracer bullet" }) + .update({ completed: !inserted.completed }) + .eq("id", inserted.id) .select() .single(); + expect(updated.error).toBeNull(); + expect(updated.data).toEqual({ ...inserted, completed: !inserted.completed }); - expect(inserted.error).toBeNull(); - expect(inserted.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); + const updatedRead = await client.from("todos").select().eq("id", inserted.id).single(); + expect(updatedRead.error).toBeNull(); + expect(updatedRead.data).toEqual(updated.data); - const deleted = await client.from("todos").delete().eq("title", "native tracer bullet"); + const deleted = await client.from("todos").delete().eq("id", inserted.id).select().single(); expect(deleted.error).toBeNull(); + expect(deleted.data).toEqual(updated.data); + + const afterDelete = await client.from("todos").select().eq("id", inserted.id); + expect(afterDelete.error).toBeNull(); + expect(afterDelete.data).toEqual([]); + }, 30_000); + + test("exposes launch-scope PostgreSQL extensions through real SQL behavior", async () => { + const sql = new Bun.SQL(stack.dbUrl); + try { + const rows = await sql.unsafe< + { + uuid: string; + randomUuid: string; + statements: number; + }[] + >(` + SELECT + extensions.uuid_generate_v4()::text AS uuid, + extensions.gen_random_uuid()::text AS "randomUuid", + (SELECT count(*)::int FROM extensions.pg_stat_statements(false)) AS statements; + `); + expect(rows[0]?.uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(rows[0]?.randomUuid).toMatch(/^[0-9a-f-]{36}$/); + expect(rows[0]?.statements).toBeGreaterThan(0); + } finally { + await sql.close(); + } }, 30_000); + test("preserves native data, endpoints, policies, and cache identity across restart", async () => { + const authResponse = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(authResponse.status).toBe(200); + await drain(authResponse); + const postgrestResponse = await fetch(`${stack.url}/rest/v1/todos?select=id&limit=1`, { + headers: { apikey: stack.publishableKey }, + }); + expect(postgrestResponse.status).toBe(200); + await drain(postgrestResponse); + expect(await stack.getServiceStatus("auth")).toMatchObject({ status: "Healthy" }); + expect(await stack.getServiceStatus("postgrest")).toMatchObject({ status: "Healthy" }); + + const markerPaths = [authCachePath, postgresCachePath, postgrestCachePath].map((path) => + join(path, ".complete"), + ); + const identityKeys = ["runtime", "releaseSet", "service", "version", "target"]; + const identitiesBefore = markerPaths.map((path) => + identityKeys.map((key) => markerValue(path, key)), + ); + for (const path of markerPaths) expect(markerValue(path, "runtime")).toBe("native"); + const before = { + url: stack.url, + dbUrl: stack.dbUrl, + manifests: markerPaths.map((path) => readFileSync(path, "utf8")), + }; + const sql = new Bun.SQL(stack.dbUrl); + try { + await sql.unsafe( + `INSERT INTO public.todos (title, completed) VALUES ('native restart persistence', true)`, + ); + } finally { + await sql.close(); + } + + await stack.stop(); + await stack.stop(); + await stack.start(); + await stack.stop(); + await stack.start(); + + expect(stack.url).toBe(before.url); + expect(stack.dbUrl).toBe(before.dbUrl); + expect(markerPaths.map((path) => readFileSync(path, "utf8"))).toEqual(before.manifests); + expect(markerPaths.map((path) => identityKeys.map((key) => markerValue(path, key)))).toEqual( + identitiesBefore, + ); + const check = new Bun.SQL(stack.dbUrl); + try { + const rows = await check.unsafe<{ title: string }[]>( + `SELECT title FROM public.todos WHERE title = 'native restart persistence'`, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.title).toBe("native restart persistence"); + } finally { + await check.close(); + } + const statuses = await stack.getStatus(); + expect(statuses.find((state) => state.name === "postgres")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "auth")?.status).toBe("Stopped"); + expect(statuses.find((state) => state.name === "postgrest")?.status).toBe("Stopped"); + + await activateWithoutDownload(stack, "auth", async () => { + const response = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + await activateWithoutDownload(stack, "postgrest", async () => { + const response = await fetch(`${stack.url}/rest/v1/todos?select=id&limit=1`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + expect(await stack.getServiceStatus("auth")).toMatchObject({ status: "Healthy" }); + expect(await stack.getServiceStatus("postgrest")).toMatchObject({ status: "Healthy" }); + expect(existsSync(sentinelMarker)).toBe(false); + }, 60_000); + test("persists JWT settings in the native Postgres database", async () => { const sql = new Bun.SQL(stack.dbUrl); try { @@ -103,4 +463,56 @@ describe("native PostgREST tracer bullet", () => { await check.close(); } }, 30_000); + + test("disposes exact native resources without deleting completed cache or data", async () => { + await activateWithoutDownload(stack, "auth", async () => { + const response = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + await activateWithoutDownload(stack, "postgrest", async () => { + const response = await fetch(`${stack.url}/rest/v1/todos?select=id&limit=1`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + + const healthyStates = await stack.getStatus(); + const ownedPids = ["postgres", "auth", "postgrest"].map((name) => { + const state = healthyStates.find((entry) => entry.name === name); + expect(state?.status).toBe("Healthy"); + if (state?.pid === null || state?.pid === undefined) { + throw new Error(`${name} did not publish a process id while healthy`); + } + return state.pid; + }); + const apiPort = Number(new URL(stack.url).port); + const dbPort = Number(new URL(stack.dbUrl).port); + expect(Number.isInteger(apiPort)).toBe(true); + expect(Number.isInteger(dbPort)).toBe(true); + + await stack.dispose(); + stackDisposed = true; + expect(ownedPids.every((pid) => !isProcessAlive(pid))).toBe(true); + await bindAndClose(apiPort); + await bindAndClose(dbPort); + expect(stagingEntries(cacheRoot)).toEqual([]); + expect( + [authCachePath, postgresCachePath, postgrestCachePath].every((path) => + existsSync(join(path, ".complete")), + ), + ).toBe(true); + expect(existsSync(dataDir)).toBe(true); + expect(existsSync(stackRoot)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(true); + expect(existsSync(sentinelMarker)).toBe(false); + + rmSync(stackRoot, { recursive: true, force: true }); + rmSync(runtimeRoot, { recursive: true, force: true }); + expect(existsSync(stackRoot)).toBe(false); + expect(existsSync(runtimeRoot)).toBe(false); + }, 60_000); });