diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 5780abad4e..5499d9f16a 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -31,6 +31,7 @@ "!apps/cli/src/command-internal/connect-errors.*", "!apps/cli/src/command-internal/container-cli.*", "!apps/cli/src/command-internal/db-*.ts", + "!apps/cli/src/command-internal/db-bootstrap/**", "!apps/cli/src/command-internal/debug-logger.*", "!apps/cli/src/command-internal/detect-content-type.*", "!apps/cli/src/command-internal/diff-engine.*", @@ -100,13 +101,8 @@ "!apps/cli/src/command-internal/size-units.ts", "!apps/cli/src/command-internal/sql-files-glob.*", "!apps/cli/src/command-internal/sql-split.*", - "!apps/cli/src/command-internal/stack-api.ts", - "!apps/cli/src/command-internal/stack-backend.ts", - "!apps/cli/src/command-internal/stack-catalog-setup.ts", - "!apps/cli/src/command-internal/stack-config.ts", - "!apps/cli/src/command-internal/stack-local-database.ts", - "!apps/cli/src/command-internal/stack-shadow.ts", - "!apps/cli/src/command-internal/stack-storage.ts", + "!apps/cli/src/command-internal/stack-*", + "!apps/cli/src/command-internal/start-*", "!apps/cli/src/command-internal/status-errors.ts", "!apps/cli/src/command-internal/status-pretty.*", "!apps/cli/src/command-internal/status-values.*", @@ -114,6 +110,7 @@ "!apps/cli/src/command-internal/string-slice-flag.*", "!apps/cli/src/command-internal/temp-paths.*", "!apps/cli/src/command-internal/tenant-*", + "!apps/cli/src/command-internal/test-db.*", "!apps/cli/src/command-internal/timestamp.format.*", "!apps/cli/src/command-internal/upgrade-notice.*", "!apps/cli/src/command-internal/upgrade-suggest.*", @@ -122,6 +119,8 @@ "!apps/cli/src/command-internal/viper-env.*", "!apps/cli/src/command-internal/workdir-project.*", "!apps/cli/src/command-internal/workdir-search.ts", - "!apps/cli/src/command-internal/workdir-validation.ts" + "!apps/cli/src/command-internal/workdir-validation.ts", + "apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts", + "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts" ] } diff --git a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts index 4d3cb98dc2..ee66e005ba 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts @@ -245,15 +245,13 @@ export function ensureNetwork( ); if (exitCode !== 0 && !isNetworkAlreadyExistsError(stderr)) { const message = stderr.trim(); - return yield* Effect.fail( - new NetworkCreateError({ - message: - message.length > 0 - ? `failed to create docker network: ${message}` - : "failed to create docker network", - reason: containerCliReason(message), - }), - ); + return yield* new NetworkCreateError({ + message: + message.length > 0 + ? `failed to create docker network: ${message}` + : "failed to create docker network", + reason: containerCliReason(message), + }); } }), ); @@ -316,15 +314,11 @@ export function ensureVolume( ); if (exitCode !== 0 && !isVolumeAlreadyExistsError(stderr)) { const message = stderr.trim(); - return yield* Effect.fail( - new VolumeCreateError({ - message: - message.length > 0 - ? `failed to create volume: ${message}` - : "failed to create volume", - reason: containerCliReason(message), - }), - ); + return yield* new VolumeCreateError({ + message: + message.length > 0 ? `failed to create volume: ${message}` : "failed to create volume", + reason: containerCliReason(message), + }); } }), ); @@ -483,15 +477,13 @@ function dockerCreateContainer( ); if (exitCode !== 0) { const message = stderr.trim(); - return yield* Effect.fail( - new ContainerCreateError({ - message: - message.length > 0 - ? `failed to create docker container: ${message}` - : "failed to create docker container", - reason: containerCliReason(message), - }), - ); + return yield* new ContainerCreateError({ + message: + message.length > 0 + ? `failed to create docker container: ${message}` + : "failed to create docker container", + reason: containerCliReason(message), + }); } return stdout.trim(); }), @@ -537,20 +529,16 @@ function dockerStartContainer( }`; const hostPort = parsePortBindError(trimmed); if (hostPort === undefined) { - return yield* Effect.fail( - new ContainerStartError({ - message: base, - reason: containerCliReason(trimmed), - }), - ); + return yield* new ContainerStartError({ + message: base, + reason: containerCliReason(trimmed), + }); } const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; - return yield* Effect.fail( - new ContainerStartError({ - message: `${base}${portConflictSuggestion(hostPort, serviceLabel)}`, - reason: "port_conflict", - }), - ); + return yield* new ContainerStartError({ + message: `${base}${portConflictSuggestion(hostPort, serviceLabel)}`, + reason: "port_conflict", + }); } }), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts index 8fc596ac8b..255ae0d3b9 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts @@ -1,11 +1,6 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { afterEach, beforeEach } from "vitest"; import { ContainerCreateError, @@ -22,17 +17,10 @@ import { createContainer, volumeExists, } from "./container-lifecycle.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import type { StartContainerSpec } from "./docker-create-args.ts"; -let workdir: string; - -beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "supabase-start-container-lifecycle-")); -}); - -afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); -}); +const workdir = useTempWorkdir("supabase-start-container-lifecycle-"); /** Per-call handler, for multi-step orchestration (volume create -> container create -> start). */ function mockSpawner( @@ -142,7 +130,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.map((containerId) => { @@ -185,12 +173,12 @@ describe("createContainer", () => { return createContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.map(() => { const create = mock.spawned.find((args) => args[0] === "create"); - expect(create).toContain(`com.supabase.cli.workdir=${workdir}`); + expect(create).toContain(`com.supabase.cli.workdir=${workdir.current}`); }), ); }, @@ -207,7 +195,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.map(() => { @@ -233,7 +221,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.map(() => { @@ -252,7 +240,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: true, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.map(() => { @@ -275,7 +263,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -296,7 +284,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -318,7 +306,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -349,7 +337,7 @@ describe("createContainer", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -391,7 +379,7 @@ describe("createContainer secretFiles", () => { const containerId = yield* createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }); @@ -459,7 +447,7 @@ describe("createContainer secretFiles", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -493,7 +481,7 @@ describe("createContainer secretFiles", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -523,7 +511,7 @@ describe("createContainer secretFiles", () => { return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.flip, @@ -585,11 +573,7 @@ describe("ensureNetwork", () => { stderr: "Error response from daemon: network with name supabase_network_proj already exists\n", })); - return ensureNetwork(mock.spawner, "supabase_network_proj", {}).pipe( - Effect.map(() => { - // Just needs to not fail — no return value to assert on. - }), - ); + return ensureNetwork(mock.spawner, "supabase_network_proj", {}); }); it.live("fails with NetworkCreateError on any other failure", () => { @@ -649,11 +633,7 @@ describe("ensureVolume", () => { exitCode: 125, stderr: "Error: volume with name supabase_db_proj already exists: volume already exists\n", })); - return ensureVolume(mock.spawner, "supabase_db_proj", {}).pipe( - Effect.map(() => { - // Just needs to not fail — no return value to assert on. - }), - ); + return ensureVolume(mock.spawner, "supabase_db_proj", {}); }); it.live("treats an already-exists rejection without the trailing sentinel as success", () => { @@ -661,11 +641,7 @@ describe("ensureVolume", () => { exitCode: 125, stderr: "volume with name supabase_db_proj already exists\n", })); - return ensureVolume(mock.spawner, "supabase_db_proj", {}).pipe( - Effect.map(() => { - // Just needs to not fail — no return value to assert on. - }), - ); + return ensureVolume(mock.spawner, "supabase_db_proj", {}); }); it.live("fails with VolumeCreateError on any other failure", () => { @@ -852,7 +828,7 @@ describe("createContainer with an empty containerName (the shadow database)", () return createContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, - workdir, + workdir: workdir.current, extraHosts: [], }).pipe( Effect.map((containerId) => { diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index 90581c32ed..b4f1d11471 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -439,12 +439,10 @@ const runStartMigrateJob = Effect.fn("DbSetup.runMigrateJob")(function* ( ), ); if (result.exitCode !== 0) { - return yield* Effect.fail( - new DbSetupError({ - message: `error running container: exit ${result.exitCode}`, - reason: "database", - }), - ); + return yield* new DbSetupError({ + message: `error running container: exit ${result.exitCode}`, + reason: "database", + }); } }); @@ -709,43 +707,24 @@ export const removeDatabaseWebhooks = Effect.fnUntraced(function* ( * separately from {@link startSetupLocalDatabase} because callers invoke it unconditionally, not * just on a fresh volume. */ -export const startInitCurrentBranch = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, -) { - const currentBranchPath = path.join(workdir, "supabase", ".branches", "_current_branch"); - const exists = yield* fs.exists(currentBranchPath).pipe( - Effect.mapError( - (error) => - new DbSetupError({ - message: `failed init current branch: ${errMessage(error)}`, - reason: "filesystem", - }), - ), - ); - if (exists) return; - yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }).pipe( - Effect.mapError( - (error) => - new DbSetupError({ - message: `failed init current branch: ${errMessage(error)}`, - reason: "filesystem", - }), - ), - ); - // Explicit mode: without it, `writeFileString` falls back to Node's default (`0666` before the - // umask), which under a permissive umask could leave this file group/world-writable. - yield* fs.writeFileString(currentBranchPath, "main", { mode: 0o644 }).pipe( - Effect.mapError( - (error) => - new DbSetupError({ - message: `failed init current branch: ${errMessage(error)}`, - reason: "filesystem", - }), - ), - ); -}); +export const startInitCurrentBranch = Effect.fnUntraced( + function* (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) { + const currentBranchPath = path.join(workdir, "supabase", ".branches", "_current_branch"); + const exists = yield* fs.exists(currentBranchPath); + if (exists) return; + yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }); + // Explicit mode: without it, `writeFileString` falls back to Node's default (`0666` before the + // umask), which under a permissive umask could leave this file group/world-writable. + yield* fs.writeFileString(currentBranchPath, "main", { mode: 0o644 }); + }, + Effect.mapError( + (error) => + new DbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + reason: "filesystem", + }), + ), +); export interface ApplyDatabaseOverlayInput { readonly webhooksEnabled: boolean; @@ -945,36 +924,35 @@ export interface FreshDbSetupInput { readonly debug: boolean; } -const connectLocalPostgres = (input: { +const connectLocalPostgres = Effect.fn("DbSetup.connectLocalPostgres")(function* (input: { readonly hostname: string; readonly dbPort: number; readonly password: string; -}) => - Effect.gen(function* () { - const dbConnection = yield* DbConnection; - let attempts = 0; - return yield* Effect.suspend(() => { - attempts += 1; - return dbConnection.connect( - { - host: input.hostname, - port: input.dbPort, - user: "postgres", - password: input.password, - database: "postgres", - }, - { isLocal: true, dnsResolver: "native" }, - ); - }).pipe( - Effect.retry({ - schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), - while: (error) => error.retryable === true, - }), - Effect.ensuring( - Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", attempts)), - ), +}) { + const dbConnection = yield* DbConnection; + let attempts = 0; + return yield* Effect.suspend(() => { + attempts += 1; + return dbConnection.connect( + { + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: input.password, + database: "postgres", + }, + { isLocal: true, dnsResolver: "native" }, ); - }).pipe(Effect.withSpan("DbSetup.connectLocalPostgres")); + }).pipe( + Effect.retry({ + schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), + while: (error) => error.retryable === true, + }), + Effect.ensuring( + Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", attempts)), + ), + ); +}); /** Converges pg_net while preserving extensions installed by user migrations. */ export const runDatabaseWebhooksSetup = (input: { diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts index eef7e3f34c..3644219133 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import type { CliConfig } from "@supabase/config"; import { CliConfigSchema } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; @@ -160,15 +157,18 @@ function mockDockerRunFails() { return { layer }; } -function makeWorkdir(): string { - return mkdtempSync(join(tmpdir(), "db-setup-")); -} +const makeWorkdir = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "db-setup-" }); +}); -function writeConfigToml(workdir: string, content: string): void { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), content); -} +const writeConfigToml = Effect.fnUntraced(function* (workdir: string, content: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), content); +}); const defaultConfig: CliConfig = decodeConfig({}); @@ -238,415 +238,420 @@ describe("startSetupLocalDatabase", () => { describe("PG <= 14 vs PG >= 15 schema branch", () => { it.effect("PG14: execs globals + the PG14 initial schema, runs no one-shot docker jobs", () => { - const workdir = makeWorkdir(); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); - expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(true); - expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); - expect(docker.runs.length).toBe(0); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker); + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(true); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); + expect(docker.runs.length).toBe(0); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("PG13: execs globals + the PG13 initial schema, not the PG14 one", () => { - const workdir = makeWorkdir(); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 13 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); - expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(true); - expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); - expect(docker.runs.length).toBe(0); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 13 }), out, docker); + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); + expect(docker.runs.length).toBe(0); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("PG15+: runs one-shot docker jobs, execs no schema SQL at all", () => { - const workdir = makeWorkdir(); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(false); - expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); - expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); - // Default config: realtime, storage, and auth are all enabled. - expect(docker.runs.length).toBe(3); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 17 }), out, docker); + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(false); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); + // Default config: realtime, storage, and auth are all enabled. + expect(docker.runs.length).toBe(3); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); }); describe("PG15+ one-shot job gating", () => { it.effect("gates each job independently on its own service's enabled flag", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ - realtime: { enabled: true }, - storage: { enabled: false }, - auth: { enabled: true }, - }); - return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(2); - const images = docker.runs.map((r) => r.image); - expect(images).toContain("public.ecr.aws/supabase/realtime:v2.34.7"); - expect(images).toContain("public.ecr.aws/supabase/gotrue:v2.170.0"); - expect(images).not.toContain("public.ecr.aws/supabase/storage-api:v1.0.0"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + yield* run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker); + expect(docker.runs.length).toBe(2); + const images = docker.runs.map((r) => r.image); + expect(images).toContain("public.ecr.aws/supabase/realtime:v2.34.7"); + expect(images).toContain("public.ecr.aws/supabase/gotrue:v2.170.0"); + expect(images).not.toContain("public.ecr.aws/supabase/storage-api:v1.0.0"); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("runs nothing when all three services are disabled", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ - realtime: { enabled: false }, - storage: { enabled: false }, - auth: { enabled: false }, - }); - return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(0); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + auth: { enabled: false }, + }); + yield* run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker); + expect(docker.runs.length).toBe(0); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( "slim refs: runs realtime, storage, and auth one-shots on the resolved slim images", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run( - baseInput(workdir, session, { - majorVersion: 15, - images: { - realtime: "ghcr.io/supabase/cli/realtime:v2.129.3", - storage: "ghcr.io/supabase/cli/storage:v1.70.3", - auth: "ghcr.io/supabase/cli/auth:v2.196.0", - }, - }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.map((job) => job.image)).toEqual([ - "ghcr.io/supabase/cli/realtime:v2.129.3", - "ghcr.io/supabase/cli/storage:v1.70.3", - "ghcr.io/supabase/cli/auth:v2.196.0", - ]); - expect(docker.runs[0]?.cmd?.[0]).toBe("/app/bin/realtime"); - expect(docker.runs[1]?.cmd).toEqual(["node", "dist/scripts/migrate-call.js"]); - expect(docker.runs[2]?.cmd).toEqual(["gotrue", "migrate"]); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run( + baseInput(workdir, session, { + majorVersion: 15, + images: { + realtime: "ghcr.io/supabase/cli/realtime:v2.129.3", + storage: "ghcr.io/supabase/cli/storage:v1.70.3", + auth: "ghcr.io/supabase/cli/auth:v2.196.0", + }, + }), + out, + docker, + ); + expect(docker.runs.map((job) => job.image)).toEqual([ + "ghcr.io/supabase/cli/realtime:v2.129.3", + "ghcr.io/supabase/cli/storage:v1.70.3", + "ghcr.io/supabase/cli/auth:v2.196.0", + ]); + expect(docker.runs[0]?.cmd?.[0]).toBe("/app/bin/realtime"); + expect(docker.runs[1]?.cmd).toEqual(["node", "dist/scripts/migrate-call.js"]); + expect(docker.runs[2]?.cmd).toEqual(["gotrue", "migrate"]); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect( "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - // Default config: realtime, storage, and auth are all enabled — 3 jobs. - return run( - baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(3); - for (const job of docker.runs) { - expect(job.labels).toEqual({ - "com.supabase.cli.project": "labeled-proj", - "com.docker.compose.project": "labeled-proj", - }); - } - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + // Default config: realtime, storage, and auth are all enabled — 3 jobs. + yield* run( + baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), + out, + docker, + ); + expect(docker.runs.length).toBe(3); + for (const job of docker.runs) { + expect(job.labels).toEqual({ + "com.supabase.cli.project": "labeled-proj", + "com.docker.compose.project": "labeled-proj", + }); + } + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect( "the realtime job's env matches `buildRealtimeEnv` on the internal db address + jwks", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); - return run( - baseInput(workdir, session, { - majorVersion: 15, - config, - dbHost: "supabase_db_myproj", - jwks: '{"keys":["stub"]}', - }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(1); - const job = docker.runs[0]!; - expect(job.cmd[0]).toBe("/app/bin/realtime"); - expect(job.cmd[1]).toBe("eval"); - expect(job.cmd[2]).toContain('Realtime.Tenants.health_check("realtime-dev")'); - expect(job.env["DB_HOST"]).toBe("supabase_db_myproj"); - expect(job.env["API_JWT_JWKS"]).toBe('{"keys":["stub"]}'); - expect(job.network).toEqual({ _tag: "named", name: "supabase_network_proj" }); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + yield* run( + baseInput(workdir, session, { + majorVersion: 15, + config, + dbHost: "supabase_db_myproj", + jwks: '{"keys":["stub"]}', + }), + out, + docker, + ); + expect(docker.runs.length).toBe(1); + const job = docker.runs[0]!; + expect(job.cmd[0]).toBe("/app/bin/realtime"); + expect(job.cmd[1]).toBe("eval"); + expect(job.cmd[2]).toContain('Realtime.Tenants.health_check("realtime-dev")'); + expect(job.env["DB_HOST"]).toBe("supabase_db_myproj"); + expect(job.env["API_JWT_JWKS"]).toBe('{"keys":["stub"]}'); + expect(job.network).toEqual({ _tag: "named", name: "supabase_network_proj" }); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect( "the auth job's env derives API_EXTERNAL_URL from apiUrl and carries site_url + jwt secret", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ realtime: { enabled: false }, storage: { enabled: false } }); - return run( - baseInput(workdir, session, { - majorVersion: 15, - config, - apiUrl: "http://127.0.0.1:54321/", - }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(1); - const job = docker.runs[0]!; - expect(job.cmd).toEqual(["gotrue", "migrate"]); - expect(job.env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); - expect(job.env["GOTRUE_SITE_URL"]).toBe(config.auth.site_url); - expect(job.env["GOTRUE_JWT_SECRET"]).toBe( - "super-secret-jwt-token-with-at-least-32-characters-long", - ); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + }); + yield* run( + baseInput(workdir, session, { + majorVersion: 15, + config, + apiUrl: "http://127.0.0.1:54321/", + }), + out, + docker, + ); + expect(docker.runs.length).toBe(1); + const job = docker.runs[0]!; + expect(job.cmd).toEqual(["gotrue", "migrate"]); + expect(job.env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(job.env["GOTRUE_SITE_URL"]).toBe(config.auth.site_url); + expect(job.env["GOTRUE_JWT_SECRET"]).toBe( + "super-secret-jwt-token-with-at-least-32-characters-long", + ); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect( "the auth job's GOTRUE_SITE_URL reflects the caller's resolved siteUrl, not the raw config value", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ - realtime: { enabled: false }, - storage: { enabled: false }, - auth: { site_url: "http://raw-config-value.example" }, - }); - return run( - baseInput(workdir, session, { - majorVersion: 15, - config, - apiUrl: "http://127.0.0.1:54321/", - siteUrl: "http://env-overridden-value.example", - }), - out, - docker, - ).pipe( - Effect.map(() => { - const job = docker.runs[0]!; - expect(job.env["GOTRUE_SITE_URL"]).toBe("http://env-overridden-value.example"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + auth: { site_url: "http://raw-config-value.example" }, + }); + yield* run( + baseInput(workdir, session, { + majorVersion: 15, + config, + apiUrl: "http://127.0.0.1:54321/", + siteUrl: "http://env-overridden-value.example", + }), + out, + docker, + ); + const job = docker.runs[0]!; + expect(job.env["GOTRUE_SITE_URL"]).toBe("http://env-overridden-value.example"); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect( "--debug tees every one-shot job's stderr, matching Go's utils.GetDebugLogger()", () => { - const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + yield* run( + baseInput(workdir, session, { majorVersion: 15, config, debug: true }), + out, + docker, + ); + expect(docker.runs.length).toBe(2); + expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("without --debug, one-shot jobs run with teeStderr off", () => { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); const { session } = fakeSession(); const out = mockOutput(); const docker = mockDockerRun(); - const config = decodeConfig({ - realtime: { enabled: true }, - storage: { enabled: false }, - auth: { enabled: true }, - }); - return run( - baseInput(workdir, session, { majorVersion: 15, config, debug: true }), + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + yield* run( + baseInput(workdir, session, { majorVersion: 15, config, debug: false }), out, docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(2); - expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); - rmSync(workdir, { recursive: true, force: true }); - }), ); - }, - ); - - it.effect("without --debug, one-shot jobs run with teeStderr off", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); - return run( - baseInput(workdir, session, { majorVersion: 15, config, debug: false }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(1); - expect(docker.captureOptsCalls).toEqual([{ teeStderr: false }]); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + expect(docker.runs.length).toBe(1); + expect(docker.captureOptsCalls).toEqual([{ teeStderr: false }]); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("a non-zero exit from a one-shot job fails the whole pipeline", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun({ exitCode: 1 }); - const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); - return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( - Effect.flip, - Effect.map((error) => { - expect(error).toBeInstanceOf(DbSetupError); - expect((error as DbSetupError).message).toBe("error running container: exit 1"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun({ exitCode: 1 }); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + const error = yield* run( + baseInput(workdir, session, { majorVersion: 15, config }), + out, + docker, + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(DbSetupError); + expect(error.message).toBe("error running container: exit 1"); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); }); describe("ApplyApiPrivileges tri-state", () => { it.effect("auto_expose_new_tables = true is a no-op (no revoke SQL exec'd)", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[api]\nauto_expose_new_tables = true\n"); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + yield* writeConfigToml(workdir, "[api]\nauto_expose_new_tables = true\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker); + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("auto_expose_new_tables = false execs the revoke SQL", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[api]\nauto_expose_new_tables = false\n"); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + yield* writeConfigToml(workdir, "[api]\nauto_expose_new_tables = false\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker); + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( "unset (no config.toml at all) matches the true behavior — no revoke SQL exec'd", () => { - const workdir = makeWorkdir(); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker); + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); }); describe("vault upsert + custom-roles seed", () => { it.effect("upserts vault secrets before seeding supabase/roles.sql", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, '[db.vault]\nmy_secret = "shh"\n'); - writeFileSync( - join(workdir, "supabase", "roles.sql"), - "grant select on all tables in schema public to custom_role;", - ); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( - Effect.map(() => { - const vaultCallIndex = calls.findIndex( - (c) => c.kind === "query" && c.sql.includes("vault.create_secret"), - ); - const rolesCallIndex = calls.findIndex( - (c) => c.kind === "exec" && c.sql.includes("custom_role"), - ); - expect(vaultCallIndex).toBeGreaterThanOrEqual(0); - expect(rolesCallIndex).toBeGreaterThanOrEqual(0); - expect(vaultCallIndex).toBeLessThan(rolesCallIndex); - expect(out.rawChunks.map((c) => c.text)).toContain("Seeding globals from roles.sql...\n"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* makeWorkdir(); + yield* writeConfigToml(workdir, '[db.vault]\nmy_secret = "shh"\n'); + yield* fs.writeFileString( + path.join(workdir, "supabase", "roles.sql"), + "grant select on all tables in schema public to custom_role;", + ); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker); + const vaultCallIndex = calls.findIndex( + (c) => c.kind === "query" && c.sql.includes("vault.create_secret"), + ); + const rolesCallIndex = calls.findIndex( + (c) => c.kind === "exec" && c.sql.includes("custom_role"), + ); + expect(vaultCallIndex).toBeGreaterThanOrEqual(0); + expect(rolesCallIndex).toBeGreaterThanOrEqual(0); + expect(vaultCallIndex).toBeLessThan(rolesCallIndex); + expect(out.rawChunks.map((c) => c.text)).toContain("Seeding globals from roles.sql...\n"); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( "prints the seed message even when supabase/roles.sql is absent, and does not error", () => { - const workdir = makeWorkdir(); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( - Effect.map(() => { - expect(out.rawChunks.map((c) => c.text)).toContain( - "Seeding globals from roles.sql...\n", - ); - const rolesCallIndex = calls.findIndex( - (c) => c.kind === "exec" && c.sql.includes("custom_role"), - ); - expect(rolesCallIndex).toBe(-1); - rmSync(workdir, { recursive: true, force: true }); - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker); + expect(out.rawChunks.map((c) => c.text)).toContain("Seeding globals from roles.sql...\n"); + const rolesCallIndex = calls.findIndex( + (c) => c.kind === "exec" && c.sql.includes("custom_role"), + ); + expect(rolesCallIndex).toBe(-1); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); }); @@ -683,11 +688,12 @@ describe("resolveDbSetupPrelude", () => { 'prints the banner BEFORE a JWKS resolution failure — matching Go\'s "initSchema" printing the banner before ever calling "initSchema15" -> "ResolveJWKS" (review: PRRT_kwDOErm0O86W6R-O)', () => { const out = mockOutput(); + const jwksFailure = new Error("jwks discovery failed"); return run( { majorVersion: 15, realtimeEnabledForSetup: true, - jwks: Effect.fail(new Error("jwks discovery failed")), + jwks: Effect.fail(jwksFailure), }, out, ).pipe( @@ -957,39 +963,41 @@ describe("ensureStackWebhookSchema", () => { describe("startInitCurrentBranch", () => { it.effect('writes supabase/.branches/_current_branch = "main" when absent', () => { - const workdir = makeWorkdir(); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* startInitCurrentBranch(fs, path, workdir); - const content = yield* fs.readFileString( - join(workdir, "supabase", ".branches", "_current_branch"), - ); - expect(content).toBe("main"); - }).pipe( - Effect.provide(BunServices.layer), - Effect.map(() => { - rmSync(workdir, { recursive: true, force: true }); - }), - ); + const fixtureFs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* startInitCurrentBranch(fs, path, workdir); + const content = yield* fs.readFileString( + path.join(workdir, "supabase", ".branches", "_current_branch"), + ); + expect(content).toBe("main"); + }); + yield* fixtureFs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("leaves an existing _current_branch file untouched", () => { - const workdir = makeWorkdir(); - const branchesDir = join(workdir, "supabase", ".branches"); - mkdirSync(branchesDir, { recursive: true }); - writeFileSync(join(branchesDir, "_current_branch"), "feature-x"); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* startInitCurrentBranch(fs, path, workdir); - const content = yield* fs.readFileString(join(branchesDir, "_current_branch")); - expect(content).toBe("feature-x"); - }).pipe( - Effect.provide(BunServices.layer), - Effect.map(() => { - rmSync(workdir, { recursive: true, force: true }); - }), - ); + const fixtureFs = yield* FileSystem.FileSystem; + const fixturePath = yield* Path.Path; + const workdir = yield* makeWorkdir(); + const branchesDir = fixturePath.join(workdir, "supabase", ".branches"); + yield* fixtureFs.makeDirectory(branchesDir, { recursive: true }); + yield* fixtureFs.writeFileString( + fixturePath.join(branchesDir, "_current_branch"), + "feature-x", + ); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* startInitCurrentBranch(fs, path, workdir); + const content = yield* fs.readFileString(path.join(branchesDir, "_current_branch")); + expect(content).toBe("feature-x"); + }); + yield* fixtureFs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.ts index 267b997177..fdd1e16c51 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -315,7 +315,7 @@ export function waitForHealthyServices( ); stillWatching = failures.map((failure) => failure.containerId); if (failures.length > 0) { - return yield* Effect.fail(new HealthCheckProbeError({ failures })); + return yield* new HealthCheckProbeError({ failures }); } }, ).pipe(Effect.withTracerEnabled(false), Effect.provideService(ChildTracePropagation, false)); @@ -342,15 +342,13 @@ export function waitForHealthyServices( opts.images, scans.find((scan) => scan.runtime !== undefined)?.runtime ?? "docker", ); - return yield* Effect.fail( - new HealthCheckTimeoutError({ - message: probeError.failures - .map((failure) => `${failure.containerId} ${failure.reason}`) - .join("\n"), - unhealthy: probeError.failures, - ...(suggestion === undefined ? {} : { suggestion }), - }), - ); + return yield* new HealthCheckTimeoutError({ + message: probeError.failures + .map((failure) => `${failure.containerId} ${failure.reason}`) + .join("\n"), + unhealthy: probeError.failures, + ...(suggestion === undefined ? {} : { suggestion }), + }); }), ), Effect.ensuring( @@ -482,13 +480,11 @@ export function waitForShadowReady( scan.runtime ?? "docker", ) : undefined; - return yield* Effect.fail( - new HealthCheckTimeoutError({ - message: `${containerId} ${failure.reason}`, - unhealthy: [{ containerId, reason: failure.reason }], - ...(suggestion === undefined ? {} : { suggestion }), - }), - ); + return yield* new HealthCheckTimeoutError({ + message: `${containerId} ${failure.reason}`, + unhealthy: [{ containerId, reason: failure.reason }], + ...(suggestion === undefined ? {} : { suggestion }), + }); }), ), Effect.ensuring( diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts index 0cf840b676..7a8a369dde 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts @@ -71,14 +71,12 @@ function mockHealthSpawner( // Mirrors a host where only one runtime is installed: `spawnContainerCli` // tries `docker` first and falls back to `podman` when the spawn fails. if (opts.runtime !== undefined && binary !== opts.runtime) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: `${binary}: command not found`, - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${binary}: command not found`, + }); } spawned.push(args); @@ -845,7 +843,7 @@ function mockShadowDbConnection( // A dial that hangs before answering — how a real attempt burns its own connect timeout. if (opts.connectMillis !== undefined) yield* Effect.sleep(opts.connectMillis); if (attempts.length <= failTimes) { - return yield* Effect.fail(new DbConnectError({ message: "connection refused" })); + return yield* new DbConnectError({ message: "connection refused" }); } yield* Effect.addFinalizer(() => Effect.sync(() => { diff --git a/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts b/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts index 3baa883787..6970f4ef54 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts @@ -96,12 +96,10 @@ export function ensureImagesCached( // The install hint is appended once after every resolve finishes, rather than emitted // from inside the resolver, to avoid duplicate hints from concurrent failures. const hint = failures.some(isDockerDaemonUnreachable) ? `\n\n${SUGGEST_DOCKER_INSTALL}` : ""; - return yield* Effect.fail( - new ImagePrepullError({ - message: `${failures.join("\n")}${hint}`, - reason: failureReason, - }), - ); + return yield* new ImagePrepullError({ + message: `${failures.join("\n")}${hint}`, + reason: failureReason, + }); } return resolved; diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts index 0ef0f625e1..8e5bd3549c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts @@ -168,12 +168,10 @@ export const resetDisconnectClients = Effect.fn("DbBootstrap.disconnectClients") isSqlState(failure.code) && failure.code !== PG_INVALID_CATALOG_NAME ) { - return yield* Effect.fail( - new DbSetupError({ - message: `failed to disconnect clients: ${failure.message}`, - reason: "database", - }), - ); + return yield* new DbSetupError({ + message: `failed to disconnect clients: ${failure.message}`, + reason: "database", + }); } } diff --git a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts index 44011cbd3d..376851bfbe 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts @@ -121,7 +121,7 @@ function restartSatelliteServices( ); const failures = results.filter(Option.isSome).map((result) => result.value); if (failures.length > 0) { - return yield* Effect.fail(new RestartServicesError({ message: failures.join("\n") })); + return yield* new RestartServicesError({ message: failures.join("\n") }); } }).pipe(Effect.withSpan("RestartServices.restartSatellites")); } @@ -189,12 +189,10 @@ function reloadKong(spawner: Spawner, projectId: string): Effect.Effect 0 - ? `failed to reload kong: error executing command:\n${trimmed}` - : "failed to reload kong: error executing command", - suggestion: kongRecoverySuggestion(kongId), - }), - ); + return yield* new KongReloadError({ + message: + trimmed.length > 0 + ? `failed to reload kong: error executing command:\n${trimmed}` + : "failed to reload kong: error executing command", + suggestion: kongRecoverySuggestion(kongId), + }); } }).pipe(Effect.withSpan("RestartServices.reloadKong")); } diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.ts index 6ecdd15040..9ee5e8af4e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import type { ContainerIdName } from "../docker-lifecycle.ts"; @@ -30,7 +30,7 @@ export const rollbackStart = ( deleteVolumes: boolean, workdir: string, debug: boolean, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // `dockerRemoveAll` doesn't print this; each caller owns its own status writer. yield* Effect.sync(() => { diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts index 7d372a83c5..77faa99f50 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts @@ -1,3 +1,4 @@ +import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { Data, Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process"; @@ -74,7 +75,7 @@ describe("rollbackStart", () => { expect(stderr).toHaveBeenCalledTimes(1); expect(stderr).toHaveBeenCalledWith("Stopping containers...\n"); expect(mock.spawned.map((args) => args[0])).toEqual(["ps", "container", "network"]); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("requests a volume prune when deleteVolumes is true", () => { @@ -94,7 +95,7 @@ describe("rollbackStart", () => { "volume", "network", ]); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("swallows a rollback failure, logging it to stderr instead of failing the effect", () => { @@ -111,7 +112,7 @@ describe("rollbackStart", () => { expect(stderr).toHaveBeenCalledTimes(2); expect(stderr).toHaveBeenNthCalledWith(1, "Stopping containers...\n"); expect(stderr).toHaveBeenNthCalledWith(2, "failed to list containers: permission denied\n"); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("logs a generic message to stderr when the underlying failure has no stderr text", () => { @@ -128,7 +129,7 @@ describe("rollbackStart", () => { expect(stderr).toHaveBeenCalledTimes(2); expect(stderr).toHaveBeenNthCalledWith(1, "Stopping containers...\n"); expect(stderr).toHaveBeenNthCalledWith(2, "failed to list containers\n"); - }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts index 2cf533bf4b..135cd87894 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts @@ -15,9 +15,8 @@ * `shadow-cache.unit.test.ts`. Nothing branch-shaped belongs here. */ -import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Option, Path } from "effect"; import { afterEach, describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../tests/helpers/cli.ts"; @@ -63,12 +62,11 @@ function isStackPortConflict(stderr: string): boolean { ); } -async function assignStackPorts( - projectDir: string, - attempt: number, -): Promise> { +const assignStackPorts = Effect.fnUntraced(function* (projectDir: string, attempt: number) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const configPath = path.join(projectDir, "supabase", "config.toml"); - const config = await readFile(configPath, "utf8"); + const config = yield* fs.readFileString(configPath); let index = 0; const base = STACK_PORT_BASE + attempt * STACK_PORT_BLOCK_SIZE; const lines = config.split("\n").map((line) => { @@ -76,38 +74,54 @@ async function assignStackPorts( if (match === null) return line; return `${match[1]}${base + index++}`; }); - await writeFile(configPath, lines.join("\n")); + yield* fs.writeFileString(configPath, lines.join("\n")); return Array.from( { length: SHADOW_PORT_CANDIDATE_COUNT }, (_, shadowIndex) => base + index + shadowIndex, ); -} +}); -async function baselineTars(cacheDir: string): Promise> { - const entries = await readdir(cacheDir).catch(() => [] as Array); +const baselineTars = Effect.fnUntraced(function* (cacheDir: string) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs + .readDirectory(cacheDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); return entries.filter((entry) => entry.endsWith(".tar")).sort(); -} +}); + +const mtimeMs = Effect.fnUntraced(function* (filePath: string) { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(filePath); + return Option.getOrThrow(info.mtime).getTime(); +}); describe("shadow baseline cache (e2e, local Docker stack)", () => { let projectDir: string | undefined; let home: ReturnType | undefined; - afterEach(async () => { - if (projectDir !== undefined) { - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a leaked local - // stack would otherwise pollute the CI runner for later jobs. - await runSupabase(["stop", "--no-backup"], { - cwd: projectDir, - exitTimeoutMs: CLEANUP_TIMEOUT_MS, - ...(home === undefined ? {} : { home: home.dir }), - }).catch(() => undefined); - await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); - } - // Disposes the temp `SUPABASE_HOME`, and with it the ~90MB baseline tar this suite published. - home?.[Symbol.dispose](); - projectDir = undefined; - home = undefined; - }); + afterEach(() => + Effect.runPromise( + Effect.gen(function* () { + if (projectDir !== undefined) { + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a leaked local + // stack would otherwise pollute the CI runner for later jobs. + yield* Effect.tryPromise(() => + runSupabase(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + ...(home === undefined ? {} : { home: home.dir }), + }), + ).pipe(Effect.ignore); + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true, force: true }).pipe(Effect.ignore); + } + // Disposes the temp `SUPABASE_HOME`, and with it the ~90MB baseline tar this suite published. + home?.[Symbol.dispose](); + projectDir = undefined; + home = undefined; + }).pipe(Effect.provide(BunServices.layer)), + ), + ); test( "publishes a baseline snapshot on the first db diff, then restores it on the second with identical output", @@ -118,148 +132,162 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { 2 * DIFF_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS, }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-shadow-cache-e2e-")); - // One temp `SUPABASE_HOME` per test run so both `db diff` processes share the same cache - // directory; `runSupabase` otherwise mints a fresh home per invocation. - home = makeTempHome(); - const cacheDir = path.join(home.dir, "cache", "shadow-baseline"); + () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + projectDir = yield* fs.makeTempDirectory({ prefix: "sb-shadow-cache-e2e-" }); + // One temp `SUPABASE_HOME` per test run so both `db diff` processes share the same cache + // directory; `runSupabase` otherwise mints a fresh home per invocation. + home = makeTempHome(); + const homeDir = home.dir; + const cacheDir = path.join(home.dir, "cache", "shadow-baseline"); - const init = await runSupabase(["init"], { - cwd: projectDir, - home: home.dir, - }); - expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + const init = yield* Effect.promise(() => + runSupabase(["init"], { + cwd: projectDir, + home: homeDir, + }), + ); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - // Exclude the heaviest, least relevant services — `db diff` only needs the local Postgres - // container reachable, same rationale as stop/status/diff. - let start: Awaited> | undefined; - let shadowPortCandidates: ReadonlyArray = []; - for (let attempt = 0; attempt < STACK_PORT_ATTEMPT_COUNT; attempt++) { - const candidates = await assignStackPorts(projectDir, attempt); - const result = await runSupabase( - ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], - { cwd: projectDir, home: home.dir, exitTimeoutMs: START_TIMEOUT_MS }, - ); - start = result; - if (result.exitCode === 0) shadowPortCandidates = candidates; - if (result.exitCode === 0 || !isStackPortConflict(result.stderr)) break; + // Exclude the heaviest, least relevant services — `db diff` only needs the local Postgres + // container reachable, same rationale as stop/status/diff. + let start: Awaited> | undefined; + let shadowPortCandidates: ReadonlyArray = []; + for (let attempt = 0; attempt < STACK_PORT_ATTEMPT_COUNT; attempt++) { + const candidates = yield* assignStackPorts(projectDir, attempt); + const result = yield* Effect.promise(() => + runSupabase( + ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], + { cwd: projectDir, home: homeDir, exitTimeoutMs: START_TIMEOUT_MS }, + ), + ); + start = result; + if (result.exitCode === 0) shadowPortCandidates = candidates; + if (result.exitCode === 0 || !isStackPortConflict(result.stderr)) break; - if (attempt === STACK_PORT_ATTEMPT_COUNT - 1) break; - const cleanup = await runSupabase(["stop", "--no-backup"], { - cwd: projectDir, - home: home.dir, - exitTimeoutMs: CLEANUP_TIMEOUT_MS, - }); - if (cleanup.exitCode !== 0) { - throw new Error( - [ - `Failed to clean up stack after port conflict (exit code ${cleanup.exitCode}).`, - `stdout:\n${cleanup.stdout}`, - `stderr:\n${cleanup.stderr}`, - ].join("\n"), - ); - } - } - expect(start).toBeDefined(); - if (start === undefined) return; - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - expect(shadowPortCandidates).toHaveLength(SHADOW_PORT_CANDIDATE_COUNT); + if (attempt === STACK_PORT_ATTEMPT_COUNT - 1) break; + const cleanup = yield* Effect.promise(() => + runSupabase(["stop", "--no-backup"], { + cwd: projectDir, + home: homeDir, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }), + ); + if (cleanup.exitCode !== 0) { + throw new Error( + [ + `Failed to clean up stack after port conflict (exit code ${cleanup.exitCode}).`, + `stdout:\n${cleanup.stdout}`, + `stderr:\n${cleanup.stderr}`, + ].join("\n"), + ); + } + } + expect(start).toBeDefined(); + if (start === undefined) return; + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + expect(shadowPortCandidates).toHaveLength(SHADOW_PORT_CANDIDATE_COUNT); - // Same drift setup as `db/diff/diff.declarative.e2e.test.ts`: create a fresh function - // directly in the local database so `db diff --local` has real, deterministic SQL to - // produce — the byte-identity of that output across cold/warm runs is the actual - // user-visible contract here. Schema files can't supply this drift since the next engine - // ignores `schema_paths` when building its migrations baseline. - const createFunction = await runSupabase( - [ - "db", - "query", - `create function public.probe_fn() + // Same drift setup as `db/diff/diff.declarative.e2e.test.ts`: create a fresh function + // directly in the local database so `db diff --local` has real, deterministic SQL to + // produce — the byte-identity of that output across cold/warm runs is the actual + // user-visible contract here. Schema files can't supply this drift since the next engine + // ignores `schema_paths` when building its migrations baseline. + const createFunction = yield* Effect.promise(() => + runSupabase( + [ + "db", + "query", + `create function public.probe_fn() returns void language sql as $$ select 1; $$;`, - "--local", - ], - { cwd: projectDir, home: home.dir }, - ); - expect( - createFunction.exitCode, - `stdout:\n${createFunction.stdout}\nstderr:\n${createFunction.stderr}`, - ).toBe(0); + "--local", + ], + { cwd: projectDir, home: homeDir }, + ), + ); + expect( + createFunction.exitCode, + `stdout:\n${createFunction.stdout}\nstderr:\n${createFunction.stderr}`, + ).toBe(0); - let cold: Awaited> | undefined; - let warm: Awaited> | undefined; - let coldTars: ReadonlyArray = []; - let coldMtimeMs = 0; + let cold: Awaited> | undefined; + let warm: Awaited> | undefined; + let coldTars: ReadonlyArray = []; + let coldMtimeMs = 0; - for (const [index, shadowPort] of shadowPortCandidates.entries()) { - const canRetry = index < shadowPortCandidates.length - 1; - // Each attempt must start from an empty cache, or the previous attempt's tar would make - // this attempt's first run a warm one. - await rm(cacheDir, { recursive: true, force: true }); - const diffOptions = { - cwd: projectDir, - home: home.dir, - exitTimeoutMs: DIFF_TIMEOUT_MS, - env: { - // Remove the harness's isolation pin (`spawnSupabase` injects `=0`) so the suite - // runs with the key genuinely absent — the shipped default-on state — rather than - // an explicit opt-in. - SUPABASE_SHADOW_CACHE: undefined, - SUPABASE_DB_SHADOW_PORT: String(shadowPort), - }, - }; + for (const [index, shadowPort] of shadowPortCandidates.entries()) { + const canRetry = index < shadowPortCandidates.length - 1; + // Each attempt must start from an empty cache, or the previous attempt's tar would make + // this attempt's first run a warm one. + yield* fs.remove(cacheDir, { recursive: true, force: true }); + const diffOptions = { + cwd: projectDir, + home: home.dir, + exitTimeoutMs: DIFF_TIMEOUT_MS, + env: { + // Remove the harness's isolation pin (`spawnSupabase` injects `=0`) so the suite + // runs with the key genuinely absent — the shipped default-on state — rather than + // an explicit opt-in. + SUPABASE_SHADOW_CACHE: undefined, + SUPABASE_DB_SHADOW_PORT: String(shadowPort), + }, + }; - const first = await runSupabase([...DIFF_ARGS], diffOptions); - if (first.exitCode !== 0 && canRetry && isShadowPortConflict(first.stderr)) continue; - cold = first; - coldTars = await baselineTars(cacheDir); - if (coldTars.length === 1) { - coldMtimeMs = (await stat(path.join(cacheDir, coldTars[0]!))).mtimeMs; - } - // A genuine cold-run failure is reported below rather than spending another full - // `DIFF_TIMEOUT_MS` on a warm run that has no snapshot to restore. - if (first.exitCode !== 0) break; + const first = yield* Effect.promise(() => runSupabase([...DIFF_ARGS], diffOptions)); + if (first.exitCode !== 0 && canRetry && isShadowPortConflict(first.stderr)) continue; + cold = first; + coldTars = yield* baselineTars(cacheDir); + if (coldTars.length === 1) { + coldMtimeMs = yield* mtimeMs(path.join(cacheDir, coldTars[0]!)); + } + // A genuine cold-run failure is reported below rather than spending another full + // `DIFF_TIMEOUT_MS` on a warm run that has no snapshot to restore. + if (first.exitCode !== 0) break; - const second = await runSupabase([...DIFF_ARGS], diffOptions); - if (second.exitCode !== 0 && canRetry && isShadowPortConflict(second.stderr)) { - cold = undefined; - continue; - } - warm = second; - break; - } + const second = yield* Effect.promise(() => runSupabase([...DIFF_ARGS], diffOptions)); + if (second.exitCode !== 0 && canRetry && isShadowPortConflict(second.stderr)) { + cold = undefined; + continue; + } + warm = second; + break; + } - // Run 1 (cold): the baseline was provisioned and exported as one keyed tar. - expect(cold, "every candidate shadow port reported a bind conflict").toBeDefined(); - if (cold === undefined) return; - expect(cold.exitCode, `stdout:\n${cold.stdout}\nstderr:\n${cold.stderr}`).toBe(0); - expect(coldTars, `cache dir: ${cacheDir}\nstderr:\n${cold.stderr}`).toHaveLength(1); - expect(coldTars[0]).toMatch(BASELINE_TAR_PATTERN); + // Run 1 (cold): the baseline was provisioned and exported as one keyed tar. + expect(cold, "every candidate shadow port reported a bind conflict").toBeDefined(); + if (cold === undefined) return; + expect(cold.exitCode, `stdout:\n${cold.stdout}\nstderr:\n${cold.stderr}`).toBe(0); + expect(coldTars, `cache dir: ${cacheDir}\nstderr:\n${cold.stderr}`).toHaveLength(1); + expect(coldTars[0]).toMatch(BASELINE_TAR_PATTERN); - // Run 2 (warm): the same key restored that snapshot instead of rebuilding it. - expect(warm).toBeDefined(); - if (warm === undefined) return; - expect(warm.exitCode, `stdout:\n${warm.stdout}\nstderr:\n${warm.stderr}`).toBe(0); - // Neither degradation path may have engaged — both warn on stderr before falling back to a - // cold provision, and either would otherwise hide a broken warm path behind a passing run. - expect(warm.stderr).not.toContain("cached shadow baseline unusable"); - expect(warm.stderr).not.toContain("shadow baseline not cached"); - const warmTars = await baselineTars(cacheDir); - expect(warmTars).toEqual(coldTars); - // Warm hits refresh mtime so a frequently used key survives LRU/TTL retention. - const warmMtimeMs = (await stat(path.join(cacheDir, warmTars[0]!))).mtimeMs; - expect(warmMtimeMs).toBeGreaterThan(coldMtimeMs); + // Run 2 (warm): the same key restored that snapshot instead of rebuilding it. + expect(warm).toBeDefined(); + if (warm === undefined) return; + expect(warm.exitCode, `stdout:\n${warm.stdout}\nstderr:\n${warm.stderr}`).toBe(0); + // Neither degradation path may have engaged — both warn on stderr before falling back to a + // cold provision, and either would otherwise hide a broken warm path behind a passing run. + expect(warm.stderr).not.toContain("cached shadow baseline unusable"); + expect(warm.stderr).not.toContain("shadow baseline not cached"); + const warmTars = yield* baselineTars(cacheDir); + expect(warmTars).toEqual(coldTars); + // Warm hits refresh mtime so a frequently used key survives LRU/TTL retention. + const warmMtimeMs = yield* mtimeMs(path.join(cacheDir, warmTars[0]!)); + expect(warmMtimeMs).toBeGreaterThan(coldMtimeMs); - // stdout carries the migration SQL (no `-f`, so `db diff` prints it); a restored cluster - // must diff to exactly the same statements as a freshly baselined one. The regex tolerates - // pretty-print variations (quoting/whitespace), same anchor as - // `diff.declarative.e2e.test.ts`. - expect(cold.stdout).toMatch( - /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i, - ); - expect(warm.stdout).toBe(cold.stdout); - }, + // stdout carries the migration SQL (no `-f`, so `db diff` prints it); a restored cluster + // must diff to exactly the same statements as a freshly baselined one. The regex tolerates + // pretty-print variations (quoting/whitespace), same anchor as + // `diff.declarative.e2e.test.ts`. + expect(cold.stdout).toMatch( + /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i, + ); + expect(warm.stdout).toBe(cold.stdout); + }).pipe(Effect.provide(BunServices.layer)), + ), ); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 1a5178c37f..67e575fa52 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -8,14 +8,21 @@ * the contract (the export must stop the container before copying and start it again after). */ -import { accessSync, chmodSync, constants } from "node:fs"; -import { join } from "node:path"; - import type { CliConfig } from "@supabase/config"; import { CliConfigSchema } from "@supabase/config"; -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { + ConfigProvider, + DateTime, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { FAKE_EMPTY_TAR, @@ -48,6 +55,8 @@ import { import { removeShadowDatabase } from "./shadow-database.ts"; import type { ShadowDbSetupInput, ShadowSetupInput } from "./shadow-database.ts"; +const nodePath = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); + const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); const defaultConfig: CliConfig = decodeConfig({}); @@ -68,7 +77,7 @@ const withShadowCacheHome = ( ): Effect.Effect => withEnvVar( "SUPABASE_HOME", - join(tempRoot.current, "_supabase_home"), + nodePath.join(tempRoot.current, "_supabase_home"), withShadowCacheEnv(value, body), ); @@ -291,7 +300,9 @@ describe("acquireShadowDatabase", () => { "shadow-baseline-0011223344556677.tar.4242.partial", ); yield* fs.writeFileString(abandoned, "stale"); - const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); + const sixMinutesAgo = DateTime.toDateUtc( + DateTime.subtract(yield* DateTime.now, { minutes: 6 }), + ); yield* fs.utimes(abandoned, sixMinutesAgo, sixMinutesAgo); const warm = yield* acquireShadowDatabase(docker.spawner, input); @@ -396,18 +407,14 @@ describe("acquireShadowDatabase", () => { // read-only root selects the doomed cold cycle on every default-ON invocation. const cacheDir = shadowCacheDir(path); yield* fs.makeDirectory(cacheDir, { recursive: true }); - chmodSync(cacheDir, 0o500); + yield* fs.chmod(cacheDir, 0o500); // chmod cannot revoke write access from a privileged user (root ignores permission // bits), so assert the degrade only when the denial is real for the current user; // otherwise the cached path proceeding is correct. - const writable = (() => { - try { - accessSync(cacheDir, constants.W_OK); - return true; - } catch { - return false; - } - })(); + const writable = yield* fs.access(cacheDir, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); const handle = yield* acquireShadowDatabase(docker.spawner, shadowInput(fs, path)); if (writable) { @@ -418,7 +425,7 @@ describe("acquireShadowDatabase", () => { expect(docker.calls("create")[0] ?? []).toContain("--rm"); expect(out.stderrText).toContain("shadow baseline cache unavailable"); } - chmodSync(cacheDir, 0o700); + yield* fs.chmod(cacheDir, 0o700); }), ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); }); @@ -601,7 +608,9 @@ describe("acquireShadowDatabase", () => { const live = path.join(tempDir, "shadow-baseline-fedcba9876543210.tar.88888.partial"); yield* fs.writeFileString(abandoned, "stale"); yield* fs.writeFileString(live, "in-flight"); - const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); + const sixMinutesAgo = DateTime.toDateUtc( + DateTime.subtract(yield* DateTime.now, { minutes: 6 }), + ); yield* fs.utimes(abandoned, sixMinutesAgo, sixMinutesAgo); yield* coldRun(docker, shadowInput(fs, path)); @@ -641,7 +650,7 @@ describe("acquireShadowDatabase", () => { // mtime is the LRU ordinal, and the rapid-fire publishes below can land within the // filesystem's timestamp granularity, making an mtime tie's "oldest" ambiguous. Age the // first tar explicitly since this test asserts keep-cap behavior, not tie-breaking. - const anHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const anHourAgo = DateTime.toDateUtc(DateTime.subtract(yield* DateTime.now, { hours: 1 })); yield* fs.utimes(path.join(shadowCacheDir(path), first[0] ?? ""), anHourAgo, anHourAgo); // Fill past keep-cap. The current key is retained, so siblings evict first. diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 48b8cf33b7..8ecf4f3542 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -10,6 +10,7 @@ import { createHash, scryptSync } from "node:crypto"; import type { CliConfig } from "@supabase/config"; import { Clock, + DateTime, Effect, Match, Option, @@ -270,9 +271,7 @@ const resolveShadowCacheKeyInputs = ( .readFileString(rolesPath) .pipe( Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed("") - : Effect.succeed(undefined), + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed("") : Effect.undefined, ), ); if (rolesSql === undefined) return Option.none(); @@ -496,7 +495,7 @@ const sweepShadowBaselineRetention = ( /** Refresh mtime on a warm hit so frequently used keys survive LRU/TTL. Best-effort. */ const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Effect.Effect => Effect.gen(function* () { - const now = new Date(yield* Clock.currentTimeMillis); + const now = yield* DateTime.nowAsDate; yield* fs.utimes(tarPath, now, now); }).pipe(Effect.orElseSucceed(() => undefined)); @@ -610,9 +609,7 @@ const exportShadowBaseline = ( .readFileString(input.path.join(input.workdir, "supabase", "roles.sql")) .pipe( Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed("") - : Effect.succeed(undefined), + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed("") : Effect.undefined, ), ); if (rolesSqlNow !== keyedRolesSql) { diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index b7f1adb7b7..cf0c6fb4ba 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -208,15 +208,7 @@ export const createShadowDatabase = ( [CLI_PROJECT_LABEL]: input.projectId, [COMPOSE_PROJECT_LABEL]: input.projectId, }; - yield* ensureNetwork(spawner, input.networkId, labels).pipe( - Effect.mapError( - (cause) => - new ShadowDbError({ - message: cause.message, - reason: shadowContainerReason(cause.reason), - }), - ), - ); + yield* ensureNetwork(spawner, input.networkId, labels); // Both overrides are the shadow baseline cache's and nobody else's — see each field's doc // comment on {@link CreateShadowDatabaseInput}. const baseSpec = buildShadowPostgresContainerSpec(input); @@ -234,17 +226,16 @@ export const createShadowDatabase = ( workdir: input.workdir, extraHosts: input.extraHosts, }; - const containerId = yield* createContainer(spawner, spec, containerOpts).pipe( - Effect.mapError( - (cause) => - new ShadowDbError({ - message: cause.message, - reason: shadowContainerReason(cause.reason), - }), - ), - ); + const containerId = yield* createContainer(spawner, spec, containerOpts); return { containerId }; }).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: cause.message, + reason: shadowContainerReason(cause.reason), + }), + ), Effect.withSpan("ShadowDatabase.create", { attributes: { "shadow.restore_archive": input.restoreArchive !== undefined }, }), diff --git a/apps/cli/src/command-internal/db-bootstrap/start-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-database.ts index 1f18672a7c..87268aef9c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -150,12 +150,10 @@ export const startDatabase = ( if (!isFreshVolume && fromBackup !== undefined) { // Refused before any container or network is created, and before freshness is published, // so rollback cannot prune it. - return yield* Effect.fail( - new StartBackupVolumeExistsError({ - message: "backup volume already exists", - suggestion: `Run ${aqua("supabase stop --no-backup")} to remove existing docker volumes.`, - }), - ); + return yield* new StartBackupVolumeExistsError({ + message: "backup volume already exists", + suggestion: `Run ${aqua("supabase stop --no-backup")} to remove existing docker volumes.`, + }); } // Print this before image resolve so a flag-off cold/failed pull still @@ -195,7 +193,7 @@ export const startDatabase = ( // inside `waitForHealthyServices` regardless. Any other failure propagates bare; see this // module's header for why `--ignore-health-check` is entirely the caller's concern. if (fromBackup === undefined) { - return yield* Effect.fail(postgresHealthResult.failure); + return yield* postgresHealthResult.failure; } } diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index d935a283cf..2942c5e4b2 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -523,9 +523,9 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues), ); if (studioEnabledForValidation && studioPortForValidation === 0) { - yield* Effect.fail( - new DbConfigLoadError({ message: "Missing required field in config: studio.port" }), - ); + return yield* new DbConfigLoadError({ + message: "Missing required field in config: studio.port", + }); } const studioApiUrlForValidation = envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? @@ -548,9 +548,9 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ), ); if (localSmtpEnabledForValidation && localSmtpPortForValidation === 0) { - yield* Effect.fail( - new DbConfigLoadError({ message: "Missing required field in config: local_smtp.port" }), - ); + return yield* new DbConfigLoadError({ + message: "Missing required field in config: local_smtp.port", + }); } // `resolveLocalConfigValues` is the same resolver `buildLocalDbContainerInputs` calls again diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 2e88682e62..f6e24993d3 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -12,6 +12,7 @@ import { Redacted, Ref, Result, + Schema, Stream, } from "effect"; import { FetchHttpClient } from "effect/http"; @@ -28,6 +29,7 @@ import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; import { stackArtifactCacheRoot } from "../../tests/helpers/stack-artifacts.ts"; const cacheRoot = stackArtifactCacheRoot; +const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); const jwtSecret = "stack-catalog-setup-integration-secret"; describe("stack catalog setup", { timeout: 180_000 }, () => { @@ -205,8 +207,8 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), }); expect(tenantJwksQuery.exitCode, errors.join("")).toBe(0); - expect(JSON.parse(tenantJwks.join("").trim())).toEqual( - JSON.parse(credentials.jwks), + expect(decodeJson(tenantJwks.join("").trim())).toEqual( + decodeJson(credentials.jwks), ); const realtime = members.find((member) => member.service === "realtime"); diff --git a/apps/cli/src/command-internal/stack-shadow-cache.integration.test.ts b/apps/cli/src/command-internal/stack-shadow-cache.integration.test.ts index 55795c2f82..8f56ba5fbc 100644 --- a/apps/cli/src/command-internal/stack-shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow-cache.integration.test.ts @@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; import { stackShadowCacheEntry } from "./stack-shadow-cache.ts"; -const defaultConfig = Schema.decodeUnknownSync(CliConfigSchema)({}); +const defaultConfig = Schema.decodeSync(CliConfigSchema)({}); const input = ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/command-internal/stack-shadow-cache.ts b/apps/cli/src/command-internal/stack-shadow-cache.ts index eec1ad8b6f..cf8f322529 100644 --- a/apps/cli/src/command-internal/stack-shadow-cache.ts +++ b/apps/cli/src/command-internal/stack-shadow-cache.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { resolveArtifact, postgresVersion } from "@supabase/stack/internal/artifacts"; import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; import { @@ -9,6 +9,10 @@ import { import { resolveSetupWebhooksEnabled, type SetupDatabaseOptions } from "./db-bootstrap/db-setup.ts"; import { viperEnvBoolWithProjectFallback } from "./viper-env.ts"; +const encodePostgresImageKey = Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(Schema.String)), +); + interface StackShadowCacheEntry { readonly key: string; readonly rolesSql: string; @@ -50,7 +54,13 @@ export const stackShadowCacheEntry = Effect.fn("StackShadowCache.entry")(functio image("realtime", input.setup.realtimeEnabledForSetup), ]); const keyInputs: ShadowCacheKeyInputs = { - postgresImage: JSON.stringify(["stack-v2", runtime, platform, arch, postgres.image]), + postgresImage: yield* encodePostgresImageKey([ + "stack-v2", + runtime, + platform, + arch, + postgres.image, + ]), majorVersion: input.setup.majorVersion, jwtSecret: input.jwtSecret, jwtExpiry: input.jwtExpiry, diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index f12e23d3f5..c724b6e1b8 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -1,4 +1,9 @@ -import { StackError, type Stack } from "@supabase/stack/effect"; +import { + StackError, + type ServiceCreationInput, + type ServiceInstances, + type Stack, +} from "@supabase/stack/effect"; import { CliConfigSchema } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -19,7 +24,7 @@ import { import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; import { orioledbVersions } from "@supabase/stack/internal/artifacts"; -const defaultConfig = Schema.decodeUnknownSync(CliConfigSchema)({}); +const defaultConfig = Schema.decodeSync(CliConfigSchema)({}); const recordingApi = ( roots: string[], @@ -38,52 +43,54 @@ const recordingApi = ( create: Effect.fn("ShadowTest.create")((options) => Effect.sync(() => roots.push(options.projectRoot)).pipe( Effect.andThen(api.create(options)), - Effect.map((stack) => ({ - ...stack, - services: { - ...stack.services, - create: Effect.fn("ShadowTest.createService")((creation) => - stack.services.create(creation).pipe( - Effect.flatMap((instance) => { - if (instance.service !== "database") return Effect.succeed(instance); - return Effect.gen(function* () { - const restored = yield* Ref.make(false); - return { - ...instance, - ready: Effect.gen(function* () { - if (yield* Ref.get(restored)) { - const remaining = yield* Ref.getAndUpdate(readyFailures, (value) => - Math.max(0, value - 1), - ); - if (remaining > 0) - return yield* new StackError({ - operation: "ready", - message: "injected ready failure", - }); - } - yield* instance.ready; - }), - restoreSnapshot: (key: string) => - Effect.gen(function* () { - const remaining = yield* Ref.getAndUpdate(failures, (value) => - Math.max(0, value - 1), - ); - if (remaining > 0) - return yield* new StackError({ - operation: "restoreSnapshot", - message: "injected restore failure", - }); - const restoredSnapshot = yield* instance.restoreSnapshot(key); - yield* Ref.set(restored, restoredSnapshot); - return restoredSnapshot; - }), - }; - }); + Effect.map((stack) => { + const createService = Effect.fn("ShadowTest.createService")(function* ( + creation: ServiceCreationInput, + ) { + const instance = yield* stack.services.create(creation); + if (instance.service !== "database") return instance; + const restored = yield* Ref.make(false); + return { + ...instance, + ready: Effect.gen(function* () { + if (yield* Ref.get(restored)) { + const remaining = yield* Ref.getAndUpdate(readyFailures, (value) => + Math.max(0, value - 1), + ); + if (remaining > 0) + return yield* new StackError({ + operation: "ready", + message: "injected ready failure", + }); + } + yield* instance.ready; + }), + restoreSnapshot: (key: string) => + Effect.gen(function* () { + const remaining = yield* Ref.getAndUpdate(failures, (value) => + Math.max(0, value - 1), + ); + if (remaining > 0) + return yield* new StackError({ + operation: "restoreSnapshot", + message: "injected restore failure", + }); + const restoredSnapshot = yield* instance.restoreSnapshot(key); + yield* Ref.set(restored, restoredSnapshot); + return restoredSnapshot; }), - ), - ), - }, - })), + }; + }); + function create( + creation: Input, + ): Effect.Effect; + function create( + creation: ServiceCreationInput, + ): Effect.Effect { + return createService(creation); + } + return { ...stack, services: { ...stack.services, create } }; + }), Effect.tap((stack) => Effect.sync(() => { handles.push(stack); @@ -430,10 +437,7 @@ describe("stack shadow databases", () => { const handles: Stack[] = []; const result = yield* stackWithShadowDatabase( input(fs, path, root), - (_handle) => - Effect.gen(function* () { - return yield* Effect.fail("consumer failed"); - }), + (_handle) => Effect.fail("consumer failed"), { runtime: "native" }, ).pipe( Effect.provide( diff --git a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts index 0d23d5bf6a..e558a0c4cf 100644 --- a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts +++ b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Crypto, Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import { FetchHttpClient } from "effect/http"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; @@ -79,7 +79,6 @@ const makeLayers = (root: string) => { const telemetry = mockTelemetryStateTracked(); const tty = mockTty({ stdinIsTty: false, stdoutIsTty: false }); return Layer.mergeAll( - BunServices.layer, FetchHttpClient.layer, runtimeInfoLayer, settings, @@ -95,7 +94,7 @@ const makeLayers = (root: string) => { Layer.succeed(CommandPlatformApiFactory, { make: Effect.die("unused") }), stdinLayer.pipe(Layer.provide(tty)), tty, - ); + ).pipe(Layer.provideMerge(BunServices.layer)); }; describe("native stack Storage gateway", () => { @@ -132,7 +131,8 @@ describe("native stack Storage gateway", () => { apiKey: credentials.apiKey, userAgent: "stack-storage-native-integration", }); - const bucket = `native-${crypto.randomUUID().slice(0, 8)}`; + const crypto = yield* Crypto.Crypto; + const bucket = `native-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; const local = path.join(root, "payload.txt"); yield* fs.writeFileString(local, "native storage payload"); diff --git a/apps/cli/src/command-internal/start-secrets-cleanup.ts b/apps/cli/src/command-internal/start-secrets-cleanup.ts index d12847188c..40a0f12235 100644 --- a/apps/cli/src/command-internal/start-secrets-cleanup.ts +++ b/apps/cli/src/command-internal/start-secrets-cleanup.ts @@ -1,7 +1,4 @@ -import { rm } from "node:fs/promises"; -import { resolve, sep } from "node:path"; - -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import type { ContainerIdName } from "./docker-lifecycle.ts"; @@ -15,27 +12,24 @@ import type { ContainerIdName } from "./docker-lifecycle.ts"; * untrusted `docker ps` metadata. `containers` must be exactly what Docker reported for the * completed teardown: a container that survived removal keeps its secrets. */ -export function cleanupStartSecrets( +export const cleanupStartSecrets = Effect.fnUntraced(function* ( containers: ReadonlyArray, fallbackWorkdir: string, -): Effect.Effect { - return Effect.tryPromise(() => - Promise.all( - containers.map((container) => { - const workdir = container.workdir.length > 0 ? container.workdir : fallbackWorkdir; - const stagingRoot = resolve(workdir, "supabase", ".temp", "start-secrets"); - const target = resolve(stagingRoot, container.name); - if (target === stagingRoot || !target.startsWith(stagingRoot + sep)) { - return Promise.resolve(); - } - return rm(target, { - recursive: true, - force: true, - }); - }), - ), - ).pipe( - Effect.asVoid, - Effect.orElseSucceed(() => undefined), - ); -} +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.forEach( + containers, + (container) => { + const workdir = container.workdir.length > 0 ? container.workdir : fallbackWorkdir; + const stagingRoot = path.resolve(workdir, "supabase", ".temp", "start-secrets"); + const target = path.resolve(stagingRoot, container.name); + if (target === stagingRoot || !target.startsWith(stagingRoot + path.sep)) { + return Effect.void; + } + /* Interruptible even in the start and stop finalizers, so the first failure ends cleanup. */ + return Effect.interruptible(fs.remove(target, { recursive: true, force: true })); + }, + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.ignore); +}); diff --git a/apps/cli/src/command-internal/start-secrets-cleanup.unit.test.ts b/apps/cli/src/command-internal/start-secrets-cleanup.unit.test.ts index 1c09a2c982..ac05e3f91b 100644 --- a/apps/cli/src/command-internal/start-secrets-cleanup.unit.test.ts +++ b/apps/cli/src/command-internal/start-secrets-cleanup.unit.test.ts @@ -1,39 +1,29 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; import { cleanupStartSecrets } from "./start-secrets-cleanup.ts"; +const makeTempWorkdir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix }); + }).pipe(Effect.provide(BunServices.layer)); + describe("cleanupStartSecrets", () => { it.effect("removes a NAMED container's secret directory keyed off container.name", () => { - const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); - const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(secretDir, { recursive: true }); - yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); - yield* cleanupStartSecrets( - [{ id: "abc123", name: "supabase_kong_demo", workdir: "" }], - workdir, - ); - expect(yield* fs.exists(secretDir)).toBe(false); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }); - - it.effect( - "falls back to fallbackWorkdir when a container carries no com.supabase.cli.workdir label", - () => { - const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); - const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); - return Effect.gen(function* () { + const workdir = yield* makeTempWorkdir("start-secrets-cleanup-"); + yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const secretDir = path.join( + workdir, + "supabase", + ".temp", + "start-secrets", + "supabase_kong_demo", + ); yield* fs.makeDirectory(secretDir, { recursive: true }); yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); yield* cleanupStartSecrets( @@ -41,73 +31,116 @@ describe("cleanupStartSecrets", () => { workdir, ); expect(yield* fs.exists(secretDir)).toBe(false); - rmSync(workdir, { recursive: true, force: true }); + yield* fs.remove(workdir, { recursive: true, force: true }); }).pipe(Effect.provide(BunServices.layer)); + }); + }); + + it.effect( + "falls back to fallbackWorkdir when a container carries no com.supabase.cli.workdir label", + () => { + return Effect.gen(function* () { + const workdir = yield* makeTempWorkdir("start-secrets-cleanup-"); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretDir = path.join( + workdir, + "supabase", + ".temp", + "start-secrets", + "supabase_kong_demo", + ); + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); + yield* cleanupStartSecrets( + [{ id: "abc123", name: "supabase_kong_demo", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); }, ); it.effect("prefers a container's OWN com.supabase.cli.workdir label over fallbackWorkdir", () => { - const ownWorkdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-own-")); - const otherWorkdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-other-")); - const secretDir = join(ownWorkdir, "supabase", ".temp", "start-secrets", "supabase_db_demo"); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(secretDir, { recursive: true }); - yield* fs.writeFileString(path.join(secretDir, "secret-0"), "db-secret"); - yield* cleanupStartSecrets( - [{ id: "abc123", name: "supabase_db_demo", workdir: ownWorkdir }], - otherWorkdir, - ); - expect(yield* fs.exists(secretDir)).toBe(false); - rmSync(ownWorkdir, { recursive: true, force: true }); - rmSync(otherWorkdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + const ownWorkdir = yield* makeTempWorkdir("start-secrets-cleanup-own-"); + const otherWorkdir = yield* makeTempWorkdir("start-secrets-cleanup-other-"); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretDir = path.join( + ownWorkdir, + "supabase", + ".temp", + "start-secrets", + "supabase_db_demo", + ); + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "db-secret"); + yield* cleanupStartSecrets( + [{ id: "abc123", name: "supabase_db_demo", workdir: ownWorkdir }], + otherWorkdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + yield* fs.remove(ownWorkdir, { recursive: true, force: true }); + yield* fs.remove(otherWorkdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); }); it.effect("never fails when nothing was ever staged for a container", () => { - const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); return Effect.gen(function* () { - yield* cleanupStartSecrets( - [{ id: "abc123", name: "supabase_realtime_demo", workdir: "" }], - workdir, - ); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + const workdir = yield* makeTempWorkdir("start-secrets-cleanup-"); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* cleanupStartSecrets( + [{ id: "abc123", name: "supabase_realtime_demo", workdir: "" }], + workdir, + ); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); }); it.effect( "refuses to delete outside the staging root when container.name contains path-traversal segments", () => { - const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); - const canary = join(workdir, "important"); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(canary, { recursive: true }); - yield* fs.writeFileString(path.join(canary, "do-not-delete"), "canary"); - yield* cleanupStartSecrets( - [{ id: "abc123", name: "../../important", workdir: "" }], - workdir, - ); - expect(yield* fs.exists(canary)).toBe(true); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + const workdir = yield* makeTempWorkdir("start-secrets-cleanup-"); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const canary = path.join(workdir, "important"); + yield* fs.makeDirectory(canary, { recursive: true }); + yield* fs.writeFileString(path.join(canary, "do-not-delete"), "canary"); + yield* cleanupStartSecrets( + [{ id: "abc123", name: "../../../important", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(canary)).toBe(true); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); }, ); it.effect("refuses to delete the whole staging root when container.name is empty", () => { - const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); - const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); - const otherProjectSecretDir = join(stagingRoot, "supabase_kong_other"); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(otherProjectSecretDir, { recursive: true }); - yield* fs.writeFileString(path.join(otherProjectSecretDir, "secret-0"), "kong-secret"); - yield* cleanupStartSecrets([{ id: "abc123", name: "", workdir: "" }], workdir); - expect(yield* fs.exists(otherProjectSecretDir)).toBe(true); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + const workdir = yield* makeTempWorkdir("start-secrets-cleanup-"); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stagingRoot = path.join(workdir, "supabase", ".temp", "start-secrets"); + const otherProjectSecretDir = path.join(stagingRoot, "supabase_kong_other"); + yield* fs.makeDirectory(otherProjectSecretDir, { recursive: true }); + yield* fs.writeFileString(path.join(otherProjectSecretDir, "secret-0"), "kong-secret"); + yield* cleanupStartSecrets([{ id: "abc123", name: "", workdir: "" }], workdir); + expect(yield* fs.exists(otherProjectSecretDir)).toBe(true); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); }); }); diff --git a/apps/cli/src/command-internal/test-db.command-handler.ts b/apps/cli/src/command-internal/test-db.command-handler.ts index f923bd1132..9a7dca3182 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -23,7 +23,7 @@ export const TEST_DB_SHORT = "Tests local database with pgTAP"; const onRunFailure = (error: TestDbRunError | TestDbNoTestsError) => Effect.gen(function* () { const output = yield* Output; - if (output.format === "text") return yield* Effect.fail(error); + if (output.format === "text") return yield* error; const processControl = yield* ProcessControl; yield* output.raw(`${error.message}\n`, "stderr"); yield* processControl.setExitCode(1); diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 178b0ac7d0..df4acb13cd 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -1,4 +1,3 @@ -import * as nodePath from "node:path"; import { Effect, FileSystem, Option, Path } from "effect"; import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; import { postgres } from "@supabase/stack/commands"; @@ -138,11 +137,9 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const target = resolveDbTargetFlags(cliArgs.args); const { setFlags } = target; if (setFlags.length > 1) { - return yield* Effect.fail( - new TestDbMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }), - ); + return yield* new TestDbMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }); } const connType = target.connType ?? "local"; @@ -150,12 +147,10 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { // `--project-ref` never implies `--linked`; see push.handler.ts's // identical guard (db push) for the rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new TestDbMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new TestDbMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const { conn, isLocal } = yield* resolver.resolve({ @@ -227,7 +222,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { // network name, so a configured `project_id` like "my project" joins // the same sanitized network the local stack created. const projectId = sanitizeProjectId( - Option.getOrElse(toml.projectId, () => nodePath.basename(cliSettings.workdir)), + Option.getOrElse(toml.projectId, () => path.basename(cliSettings.workdir)), ); return { _tag: "named" as const, name: `supabase_network_${projectId}` }; }) @@ -297,8 +292,8 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const hostWorkingDir = hostPath === undefined ? undefined - : nodePath.extname(hostPath) !== "" - ? nodePath.dirname(hostPath) + : path.extname(hostPath) !== "" + ? path.dirname(hostPath) : hostPath; return yield* managedStack.stack.commands .run(postgres.pgProve({ major: managedStack.major }), { @@ -327,8 +322,8 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const hostWorkingDir = hostPath === undefined ? undefined - : nodePath.extname(hostPath) !== "" - ? nodePath.dirname(hostPath) + : path.extname(hostPath) !== "" + ? path.dirname(hostPath) : hostPath; const nativeArgs = ["pg_prove", "--ext", ".pg", "--ext", ".sql", "-r", ...args.hostPaths]; if (debug) nativeArgs.push("--verbose"); @@ -374,22 +369,18 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { // Non-zero pg_prove exit fails the command; the TAP failure detail has // already streamed to stdout. if (exitCode !== 0) { - return yield* Effect.fail( - new TestDbRunError({ - message: `error running ${managedStack !== undefined || useNativeProve ? "pg_prove" : "container"}: exit ${exitCode}`, - }), - ); + return yield* new TestDbRunError({ + message: `error running ${managedStack !== undefined || useNativeProve ? "pg_prove" : "container"}: exit ${exitCode}`, + }); } // A stream that ends without a trailing newline leaves the verdict unterminated. const finalVerdict = pendingLine.startsWith(VERDICT_PREFIX) ? pendingLine : lastVerdict; const aggregatedFiles = FILES_SUMMARY.exec(lastSummary)?.[1]; if (finalVerdict.trimEnd() === NO_TESTS_VERDICT && aggregatedFiles === "0") { - return yield* Effect.fail( - new TestDbNoTestsError({ - message: `no pgTAP tests found in ${args.hostPaths.join(", ")}`, - }), - ); + return yield* new TestDbNoTestsError({ + message: `no pgTAP tests found in ${args.hostPaths.join(", ")}`, + }); } }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index 4761d8d747..dbcc29c2e1 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -1,8 +1,6 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -100,10 +98,10 @@ function mockDbConnection(opts: { Effect.gen(function* () { execCalls.push(sql); if (opts.enableFails === true && sql.includes("create extension")) { - return yield* Effect.fail(new DbExecError({ message: "permission denied" })); + return yield* new DbExecError({ message: "permission denied" }); } if (opts.dropFails === true && sql.includes("drop extension")) { - return yield* Effect.fail(new DbExecError({ message: "cannot drop" })); + return yield* new DbExecError({ message: "cannot drop" }); } }), // `test db` never runs a migration batch; keep the seam explicit rather than silent. @@ -214,13 +212,11 @@ function mockBundledPostgresClient(opts: { Effect.gen(function* () { allOpts.push(runOpts); if (opts.runFails === true) { - return yield* Effect.fail( - new DockerRunError({ - message: "failed to run docker: not found", - reason: "spawn", - daemonDown: false, - }), - ); + return yield* new DockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }); } const encoder = new TextEncoder(); for (const chunk of opts.stdout ?? []) { @@ -314,6 +310,8 @@ const flags = (over: Partial[0]> = {}) => ({ projectRef: over.projectRef ?? Option.none(), }); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + describe("test db integration", () => { it.live("runs pgTAP on the local db: enables then drops pgtap, exits 0", () => { const { layer, connection, docker } = setup(); @@ -505,7 +503,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to enable pgTAP: permission denied"); + expect(encodeJson(exit.cause)).toContain("failed to enable pgTAP: permission denied"); } }).pipe(Effect.provide(layer)); }); @@ -516,7 +514,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + expect(encodeJson(exit.cause)).toContain("failed to connect to postgres"); } }).pipe(Effect.provide(layer)); }); @@ -527,7 +525,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("error running container: exit 3"); + expect(encodeJson(exit.cause)).toContain("error running container: exit 3"); } }).pipe(Effect.provide(layer)); }); @@ -541,9 +539,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags({ paths: ["tests/db"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "no pgTAP tests found in /work/project/tests/db", - ); + expect(encodeJson(exit.cause)).toContain("no pgTAP tests found in /work/project/tests/db"); } }).pipe(Effect.provide(layer)); }); @@ -646,7 +642,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to run docker"); + expect(encodeJson(exit.cause)).toContain("failed to run docker"); } }).pipe(Effect.provide(layer)); }); @@ -673,7 +669,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(encodeJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); } @@ -686,7 +682,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(encodeJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); } @@ -695,12 +691,10 @@ describe("test db integration", () => { it.live("--linked=false routes to the linked branch (Changed, not value)", () => { const { layer } = setup({ args: ["--linked=false"] }); - return Effect.gen(function* () { - // No explicit assertion: success means routing reached resolver.resolve - // with connType "linked" instead of failing on mutual exclusion or the - // local-target guard. - yield* testDb(flags()); - }).pipe(Effect.provide(layer)); + // No explicit assertion: success means routing reached resolver.resolve + // with connType "linked" instead of failing on mutual exclusion or the + // local-target guard. + return testDb(flags()).pipe(Effect.provide(layer)); }); it.live("tests the project given via --project-ref --linked", () => { @@ -722,7 +716,7 @@ describe("test db integration", () => { const exit = yield* Effect.exit(testDb(flags({ projectRef: Option.some(FLAG_REF) }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(encodeJson(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } @@ -773,16 +767,23 @@ describe("test db integration", () => { const tempWorkdir = useTempWorkdir(); it.live("sanitizes a configured project_id when naming the local network (Go parity)", () => { const workdir = tempWorkdir.current; - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "My Project"\n'); - const { layer, docker } = setup({ workdir }); return Effect.gen(function* () { - yield* testDb(flags()); - expect(docker.lastOpts?.network).toEqual({ - _tag: "named", - name: "supabase_network_My_Project", - }); - }).pipe(Effect.provide(layer)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + 'project_id = "My Project"\n', + ); + const { layer, docker } = setup({ workdir }); + yield* Effect.gen(function* () { + yield* testDb(flags()); + expect(docker.lastOpts?.network).toEqual({ + _tag: "named", + name: "supabase_network_My_Project", + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("mints a login role through the real resolver for --linked --project-ref", () => { diff --git a/apps/cli/src/command-internal/test-db.layers.unit.test.ts b/apps/cli/src/command-internal/test-db.layers.unit.test.ts index b1038fa719..d4943a57f1 100644 --- a/apps/cli/src/command-internal/test-db.layers.unit.test.ts +++ b/apps/cli/src/command-internal/test-db.layers.unit.test.ts @@ -94,7 +94,9 @@ describe("testDbRuntimeLayer — IdentityStitch exposure", () => { return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(IdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(testDbRuntimeLayer(["test", "db"])), Effect.provide(ambientStubs())); + }).pipe( + Effect.provide(testDbRuntimeLayer(["test", "db"]).pipe(Layer.provideMerge(ambientStubs()))), + ); }, ); }); diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index 25e142532a..27583e513e 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -188,9 +188,10 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { const stdout = options.stdout; return Effect.gen(function* () { yield* stdout(new TextEncoder().encode(partialTap)); - return yield* Effect.fail( - new StackError({ operation: "command", message: "pg_prove unavailable" }), - ); + return yield* new StackError({ + operation: "command", + message: "pg_prove unavailable", + }); }); } return run; diff --git a/apps/cli/src/command-internal/test-db.pg-prove-args.ts b/apps/cli/src/command-internal/test-db.pg-prove-args.ts index 1e7a820e1a..736b0652c2 100644 --- a/apps/cli/src/command-internal/test-db.pg-prove-args.ts +++ b/apps/cli/src/command-internal/test-db.pg-prove-args.ts @@ -1,8 +1,11 @@ -import * as nodePath from "node:path"; -import { Option } from "effect"; +import { BunPath } from "@effect/platform-bun"; +import { Effect, Option, Path } from "effect"; import { postgresMajor } from "@supabase/stack/internal/artifacts"; import { toDockerMountPath } from "./docker-path.ts"; +const nodePath = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); +const posixPath = Effect.runSync(Effect.provide(Path.Path, BunPath.layerPosix)); + interface PostgresClientMount { readonly source: string; readonly target: string; @@ -71,7 +74,7 @@ export function buildPgProveArgs(opts: { // `\ir ./sibling.sql` includes resolve relative to the test file's own // directory; a single-file bind would leave siblings absent in the // container. Directories are mounted as-is. - const isFile = nodePath.posix.extname(dockerPath) !== ""; + const isFile = posixPath.extname(dockerPath) !== ""; const hostMount = isFile ? nodePath.dirname(fp) : fp; const dockerMount = toDockerMountPath(hostMount);