Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ suppressed; a single structured result is emitted for the whole command:
channel — it always reuses the create-resolved password (`created.dbPassword`). There is no
flag-vs-env distinction to preserve once the call is in-process (unlike the former subprocess
delegation, CLI-1617, which had to route the resolved password across process boundaries).
- **Retry**: the native push step is wrapped in the same `bootstrapRetryNotify()` +
- **Retry**: the native push step is wrapped in the same `bootstrapRetryNotify` +
`Effect.retry(retry)` policy as the api-keys and health-poll steps. A retried attempt re-runs
the whole push (connect, list pending migrations/seeds/roles, prompt, apply).
- The api-keys, health, and push retries use the same backoff policy:
Expand Down
91 changes: 42 additions & 49 deletions apps/cli/src/commands/bootstrap/bootstrap.handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Effect, FileSystem, Option, Path, Schedule } from "effect";
import * as HttpClientError from "effect/http/HttpClientError";
import { Effect, FileSystem, Option, Path, Ref, Schedule } from "effect";
import type * as HttpClientError from "effect/http/HttpClientError";

import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts";
import { CliConfigKeys } from "../../config/cli-config-keys.ts";
Expand Down Expand Up @@ -89,10 +89,10 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
// process mutation; every step reads its own `workdir` var, never `process.cwd()`, so nothing
// else depends on the chdir staying in effect.
const originalCwd = process.cwd();
let createdRef: string | undefined;
const createdRef = yield* Ref.make<string | undefined>(undefined);
// Hoisted so the linked-project-cache finalizer writes beside the other `supabase/.temp/`
// files instead of `cliSettings.workdir`.
let resolvedWorkdir: string | undefined;
const resolvedWorkdir = yield* Ref.make<string | undefined>(undefined);

yield* Effect.gen(function* () {
// Reads the prefixed `SUPABASE_WORKDIR` only (never plain `WORKDIR`).
Expand All @@ -109,7 +109,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
const workdir = path.isAbsolute(workdirInput)
? workdirInput
: path.join(runtimeInfo.cwd, workdirInput);
resolvedWorkdir = workdir;
yield* Ref.set(resolvedWorkdir, workdir);

const samples = yield* templateService.listSamples;
const allTemplates = [...samples, SCRATCH_TEMPLATE];
Expand Down Expand Up @@ -208,12 +208,12 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
emitStructuredResult: false,
});
const projectRef = created.ref;
createdRef = projectRef.length > 0 ? projectRef : undefined;
yield* Ref.set(createdRef, projectRef.length > 0 ? projectRef : undefined);
yield* Effect.annotateCurrentSpan("project.ref", projectRef);

// A fresh notifier per retry block keeps this step's "Retry (n/8)" counter independent of
// the health-poll and push retries below.
const apiKeysNotify = bootstrapRetryNotify();
const apiKeysNotify = yield* bootstrapRetryNotify;
const keys = yield* Effect.gen(function* () {
if (isText) yield* output.raw("Linking project...\n", "stderr");
return yield* getProjectApiKeys(projectRef);
Expand Down Expand Up @@ -247,12 +247,20 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
}),
);

const healthNotify = bootstrapRetryNotify();
const healthNotify = yield* bootstrapRetryNotify;
yield* Effect.gen(function* () {
if (isText) yield* output.raw("Checking project health...\n", "stderr");
const services = yield* api.v1
.getServicesHealth({ ref: projectRef, services: ["db"] })
.pipe(Effect.catch(mapHealthError));
const services = yield* api.v1.getServicesHealth({ ref: projectRef, services: ["db"] }).pipe(
Effect.catchTags({
HttpClientError: mapHealthHttpError,
SchemaError: (cause) =>
Effect.fail(
new BootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true }),
),
HttpBodyError: healthTransportError,
SupabaseApiInputError: healthTransportError,
}),
);
for (const service of services) {
if (!service.healthy) {
return yield* new BootstrapHealthError({
Expand All @@ -272,8 +280,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
const dbConfig = deriveDbConfig(projectRef, created.dbPassword, cliSettings.projectHost);
const supabaseUrl = `https://${projectRef}.${cliSettings.projectHost}`;
const envFilePath = path.join(workdir, ".env");
let envFileWritten = true;
yield* configValues.writeThrough(
const envFileWritten = yield* configValues.writeThrough(
Effect.gen(function* () {
const examplePath = path.join(workdir, ".env.example");
const hasExample = yield* fs.exists(examplePath);
Expand All @@ -291,14 +298,11 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
const env = buildDotEnv(keys, dbConfig, supabaseUrl, example);
yield* fs.writeFileString(envFilePath, marshalDotEnv(env));
}).pipe(
Effect.as(true),
Effect.catch((cause) =>
Effect.gen(function* () {
envFileWritten = false;
yield* output.raw(
`Failed to create .env file: ${cause instanceof Error ? cause.message : String(cause)}\n`,
"stderr",
);
}),
output
.raw(`Failed to create .env file: ${cause.message}\n`, "stderr")
.pipe(Effect.as(false)),
),
Effect.withSpan("bootstrap.writeDotEnv"),
),
Expand Down Expand Up @@ -328,7 +332,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
// Passes workdir/projectRef/toml through directly rather than calling the full `dbPush`
// command, since its CommandSettings-based resolvers would be stale after this handler's
// own chdir above.
const pushNotify = bootstrapRetryNotify();
const pushNotify = yield* bootstrapRetryNotify;
const seed = yield* resolveDbSeedInput(
yield* configValues.load({ workdir, projectRef: Option.some(projectRef) }),
{ workdir, ref: projectRef },
Expand Down Expand Up @@ -375,41 +379,30 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
}),
),
Effect.ensuring(
Effect.suspend(() =>
createdRef === undefined
? Effect.void
: linkedProjectCache.cache(createdRef, resolvedWorkdir),
Effect.flatMap(
Effect.all([Ref.get(createdRef), Ref.get(resolvedWorkdir)]),
([ref, workdir]) =>
ref === undefined ? Effect.void : linkedProjectCache.cache(ref, workdir),
),
),
Effect.ensuring(telemetryState.flush),
);
});

// True when `cause` is the generated client's undecodable `SchemaError`, not a transport failure.
function isDecodeFailureCause(cause: unknown): boolean {
if (typeof cause !== "object" || cause === null || !("_tag" in cause)) {
return false;
}
return cause._tag === "SchemaError";
}
const healthTransportError = (cause: unknown) =>
Effect.fail(new BootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }));

// Non-200 branch: `Error status <status>: <body>`.
const mapHealthError = (cause: unknown): Effect.Effect<never, BootstrapHealthError> => {
if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
const status = cause.response.status;
return cause.response.text.pipe(
Effect.orElseSucceed(() => ""),
Effect.map(sanitizeErrorBody),
Effect.flatMap((body) =>
Effect.fail(
new BootstrapHealthError({ message: `Error status ${status}: ${body}`, status }),
),
),
);
}
return Effect.fail(
isDecodeFailureCause(cause)
? new BootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true })
: new BootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }),
const mapHealthHttpError = (
cause: HttpClientError.HttpClientError,
): Effect.Effect<never, BootstrapHealthError> => {
if (cause.response === undefined) return healthTransportError(cause);
const status = cause.response.status;
return cause.response.text.pipe(
Effect.orElseSucceed(() => ""),
Effect.map(sanitizeErrorBody),
Effect.flatMap((body) =>
Effect.fail(new BootstrapHealthError({ message: `Error status ${status}: ${body}`, status })),
),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe("bootstrap integration", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live(
it.effect(
"scratch scaffolding fails closed on an invalid SUPABASE_EXPERIMENTAL_STACK before writing config",
() =>
Effect.gen(function* () {
Expand Down Expand Up @@ -391,7 +391,7 @@ describe("bootstrap integration", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("rejects an unknown template argument", () =>
it.effect("rejects an unknown template argument", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const s = setup(path, { samples: [NEXTJS_TEMPLATE] });
Expand Down Expand Up @@ -453,7 +453,7 @@ describe("bootstrap integration", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("aborts when the user declines to overwrite a non-empty workdir", () =>
it.effect("aborts when the user declines to overwrite a non-empty workdir", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
VALID_TOKEN,
isolatedHomeLayer,
mockCommandPlatformApiService,
mockCommandSettings,
mockCommandCredentialsLayer,
mockLinkedProjectCacheLayer,
Expand All @@ -37,7 +38,6 @@ import {
} from "../../command-internal/global-flags.ts";

import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts";
import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts";
import { ProjectRefResolver } from "../../config/project-ref.service.ts";
import { IdentityStitch } from "../../command-internal/identity-stitch.ts";
import { TemplateService } from "./bootstrap.templates.ts";
Expand Down Expand Up @@ -67,10 +67,7 @@ function ambientStubs() {
// CommandPlatformApiFactory, and ProjectRefResolver as satisfiable in the outer context; the
// runtime layer's own provisions take precedence at runtime.
const heavyServiceStubs = Layer.mergeAll(
Layer.succeed(CommandPlatformApi, {
v1: new Proxy({}, { get: () => () => Effect.die("not needed for layer-exposure test") }),
executeRaw: () => Effect.die("not needed for layer-exposure test"),
} as unknown as import("@supabase/api/effect").ApiClient),
mockCommandPlatformApiService().layer,
Layer.succeed(CommandPlatformApiFactory, {
make: Effect.die("platform-api-factory not needed for layer-exposure test"),
}),
Expand Down Expand Up @@ -112,7 +109,7 @@ function ambientStubs() {
}

describe("bootstrapRuntimeLayer — IdentityStitch exposure", () => {
it.live(
it.effect(
"exposes IdentityStitch at top level so withCommandTelemetry can read stitchedDistinctId()",
() => {
return Effect.gen(function* () {
Expand Down
15 changes: 7 additions & 8 deletions apps/cli/src/commands/bootstrap/bootstrap.retry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Duration, Effect, Random, Schedule } from "effect";
import { Duration, Effect, Random, Ref, Schedule } from "effect";

import { DebugFlag } from "../../command-internal/global-flags.ts";
import { Output } from "../../shared/output/output.service.ts";
Expand Down Expand Up @@ -31,15 +31,15 @@ export const bootstrapBackoff = Schedule.exponential("3 seconds", 1.5).pipe(
* `--debug`) for the first two failures, then to stderr from the third on. Never fires for the
* final, exhausted attempt.
*
* Returns a fresh wrapper with its own failure counter per call.
* Yields a fresh wrapper with its own failure counter per evaluation.
*/
export const bootstrapRetryNotify = () => {
let failureCount = 0;
export const bootstrapRetryNotify = Effect.gen(function* () {
const failures = yield* Ref.make(0);
return <A, E, R>(operation: Effect.Effect<A, E, R>) =>
operation.pipe(
Effect.tapError((error) =>
Effect.gen(function* () {
failureCount += 1;
const failureCount = yield* Ref.updateAndGet(failures, (count) => count + 1);
yield* Effect.annotateCurrentSpan("retry.failure_count", failureCount);
// No notify on the final, exhausted attempt.
if (failureCount > BOOTSTRAP_MAX_RETRIES) return;
Expand All @@ -56,12 +56,11 @@ export const bootstrapRetryNotify = () => {
}),
),
);
};
});

function stringifyError(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message: unknown }).message;
if (typeof message === "string") return message;
if (typeof error.message === "string") return error.message;
}
return String(error);
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,9 @@ const BOOM = new Error("boom");
// Always-failing effect retried 8 times (9 attempts) to exercise the notify routing.
const runNotify = (opts: { debug: boolean; error?: unknown }) => {
const out = mockOutput({ format: "text" });
const notify = bootstrapRetryNotify();
const program = Effect.fail(opts.error ?? BOOM).pipe(
notify,
Effect.retry({ times: 8 }),
Effect.exit,
);
return Effect.gen(function* () {
yield* program;
const notify = yield* bootstrapRetryNotify;
yield* Effect.fail(opts.error ?? BOOM).pipe(notify, Effect.retry({ times: 8 }), Effect.exit);
return out;
}).pipe(Effect.provide(Layer.mergeAll(out.layer, Layer.succeed(DebugFlag, opts.debug))));
};
Expand Down
10 changes: 5 additions & 5 deletions apps/cli/src/commands/bootstrap/bootstrap.suggest.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { suggestAppStart } from "./bootstrap.suggest.ts";
// Colour is identity here so the assertions match the established non-TTY
// (uncoloured) output byte-for-byte.
describe("suggestAppStart", () => {
it.live("suggests the start command when the workdir is the current directory", () =>
it.effect("suggests the start command when the workdir is the current directory", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
expect(suggestAppStart(path, "/home/me/app", "/home/me/app", "npm ci && npm run dev")).toBe(
Expand All @@ -16,7 +16,7 @@ describe("suggestAppStart", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("prefixes a cd line when the workdir is nested", () =>
it.effect("prefixes a cd line when the workdir is nested", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
expect(suggestAppStart(path, "/home/me", "/home/me/app", "npm ci && npm run dev")).toBe(
Expand All @@ -25,7 +25,7 @@ describe("suggestAppStart", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("omits the cd line for a '.' relative path", () =>
it.effect("omits the cd line for a '.' relative path", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
expect(suggestAppStart(path, "/home/me/app", "/home/me/app", "supabase start")).toBe(
Expand All @@ -34,7 +34,7 @@ describe("suggestAppStart", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("omits the command line when the start command is empty", () =>
it.effect("omits the command line when the start command is empty", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
expect(suggestAppStart(path, "/home/me", "/home/me/app", "")).toBe(
Expand All @@ -43,7 +43,7 @@ describe("suggestAppStart", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("applies the colorize callback to each command line", () =>
it.effect("applies the colorize callback to each command line", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const aqua = (line: string) => `<${line}>`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ function setupDownload(listings: Readonly<Record<string, ReadonlyArray<DownloadE
}

describe("bootstrap template service", () => {
it.live("lists samples with a bearer header when settings carry GITHUB_TOKEN", () => {
it.effect("lists samples with a bearer header when settings carry GITHUB_TOKEN", () => {
const s = setup({ githubToken: "gh-tok" });
return Effect.gen(function* () {
const service = yield* TemplateService;
Expand All @@ -118,7 +118,7 @@ describe("bootstrap template service", () => {
}).pipe(Effect.provide(s.layer));
});

it.live("lists samples anonymously when no GITHUB_TOKEN is captured", () => {
it.effect("lists samples anonymously when no GITHUB_TOKEN is captured", () => {
const s = setup();
return Effect.gen(function* () {
const service = yield* TemplateService;
Expand All @@ -128,7 +128,7 @@ describe("bootstrap template service", () => {
}).pipe(Effect.provide(s.layer));
});

it.live("fails with the parse error when samples.json is not json", () => {
it.effect("fails with the parse error when samples.json is not json", () => {
const s = setup({ content: "not json" });
return Effect.gen(function* () {
const service = yield* TemplateService;
Expand All @@ -138,7 +138,7 @@ describe("bootstrap template service", () => {
}).pipe(Effect.provide(s.layer));
});

it.live("downloads a template tree below its root into the target directory", () => {
it.effect("downloads a template tree below its root into the target directory", () => {
const s = setupDownload({
"examples/app": [
{ type: "file", path: "examples/app/a.txt", download_url: "https://files.test/a.txt" },
Expand All @@ -161,7 +161,7 @@ describe("bootstrap template service", () => {
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(s.layer, BunServices.layer)));
});

it.live("rejects a listing entry that escapes the target directory", () => {
it.effect("rejects a listing entry that escapes the target directory", () => {
const s = setupDownload({
"examples/app": [
{ type: "file", path: "../escape.txt", download_url: "https://files.test/x" },
Expand All @@ -181,7 +181,7 @@ describe("bootstrap template service", () => {
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(s.layer, BunServices.layer)));
});

it.live("rejects a file entry with no download url", () => {
it.effect("rejects a file entry with no download url", () => {
const s = setupDownload({
"examples/app": [{ type: "file", path: "examples/app/big.bin", download_url: null }],
});
Expand Down
Loading
Loading